Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 未发布

- 保留路径不再给没有可用签名的 thinking 补 `signature: ""`:缺失、空、短于 24、dummy 的删掉;签名可用的留,空正文也留。这一支里的 `redacted_thinking` 不看 data 和签名,原样留;不走保留路径时仍按原规则删。5.5 的非保留路径仍接受短的非 dummy 签名。没有按账号归属过滤签名。
- 协议页新增「thinking 显示」,`routing.compatibility.thinking_display` 默认 `summarized`。调用方打开 thinking 却要求隐藏(`omitted`、`updates`,或默认隐藏的模型上没写 display)时,出站改成 `summarized`;显式 `passthrough` 保留调用方的值。已保存的 `routing.json` 不写这个键也按 summarized 生效。

## 1.3.199 — 2026-10-19

- 修复集群槽位凭据 pull 时 bytes 相同仍回填 VM 元数据;已运行容器不重建;节点凭证不被旧副本覆盖;原手动禁用 / 撤销 / 冷却保留。(#589)
Expand Down
8 changes: 5 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,20 @@ Rikka / 客户端 `search_web`、`scrape_web` **不是** Anthropic 自带搜索
1. `thinking` 即使文本为空,只要有完整签名也保留;这是 `display: omitted` 的正常响应形态。
2. Opus / Sonnet / Haiku 5.5 按 sub2api:非空且非 dummy 的签名都算有效,不看长度;`redacted_thinking` 有 `data` 就留,没有 `data` 时再看签名。历史里只要有一块无效,整段 thinking / `redacted_thinking` 都拆掉,只留正文;拆完没有内容的轮删掉。全部有效时不拆。
3. 更早的型号仍逐块过滤:签名短于 24、空签名、dummy 签名删掉,合法签名块留下。`redacted_thinking` 不看 `data`,只在签名达到 24 字符时留。
4. 保留路径(cli-hop 首跳,以及默认 HTTP hop)不走上面的拆链。缺少 `signature` 字段的 `thinking` 补空字段,已有字段不改写。
4. 保留路径(cli-hop 首跳,以及默认 HTTP hop)不走上面的拆链,只在 `stripInvalidThinkingBlocks` 的这一支里处理:`redacted_thinking` 不看 `data` 和签名,原样留。`thinking` 只有签名可用(非空、不少于 24、非 dummy)才留,空正文也留;缺失、空、短于 24、dummy 的删掉,不再补 `signature: ""`。不走保留路径时,`redacted_thinking` 仍按上面第 2、3 条删。当前没有按账号归属过滤签名的步骤。

HTTP hop 与 cli-hop(`prepareCliHopBody`)共用内容预过滤,但缓存职责不同。cli-hop 首跳保留 thinking 指纹,缺失签名补 `signature: ""`;签名修复重试才清除。官方家族开启 `official_cc_passthrough` 时,Node 保留 system/tools/messages 的 caller marker、显式 TTL、缺失 TTL 和位置,不添点、不裁剪;超过四点、thinking marker、非法 TTL 或 5m(含缺失)后出现 1h,在调度前返回 `400 invalid_cache_control`。
HTTP hop 与 cli-hop(`prepareCliHopBody`)共用内容预过滤,但缓存职责不同。cli-hop 首跳保留签名可用的 thinking 指纹,没有可用签名的删掉;签名修复重试才清除其余块。官方家族开启 `official_cc_passthrough` 时,Node 保留 system/tools/messages 的 caller marker、显式 TTL、缺失 TTL 和位置,不添点、不裁剪;超过四点、thinking marker、非法 TTL 或 5m(含缺失)后出现 1h,在调度前返回 `400 invalid_cache_control`。

关闭透传或非官方请求仍走 Node rewrite:清除 caller marker,生成最后消息及 `messages.length>=4` 时倒数第二 user 的点,TTL 用入站会话 pin 值。应用 persona 后,模板显式 TTL 保留,只补自产缺失 TTL;超额时优先删 tools、system 尾部的自产点。非法模板返回 request-scoped 400,零上游执行,不换号、不冷却。

native CLI `2.1.296` 保留带 billing 的 wire system(含官方 CLI / 子代理布局)、wire tools 和 wire messages,不自动给最后一个 tool 加点;caller tool 的缺失 TTL 也保持缺失。仅对 CLI 自有 marker 补 TTL,优先请求显式 TTL(混合时 5m)→ slot `kernel.json.default_cache_ttl` → 1h,并按保留内容占用后的剩余预算裁剪。Rust kernel 不增删 marker、不重打 last、不自动降级 TTL。Node native envelope 不用菜单 TTL 重分类上游 usage,日志/计费保留真实 5m/1h 分桶。Node 对象不等于最终 wire;HTTP hop 原有独立缓存整流不变。

长度够的签名原样转发,由 Anthropic 验。5.5 的非 dummy 签名也原样转发,不按 24 字符预过滤。签名是 opaque 加密内容,当前模型 / 账户 / 会话前缀不匹配时可能被丢弃或返回 400 `Invalid \`signature\` in \`thinking\` block`。网关不伪造真实签名,只在面板 / cli-hop 保留路径补齐缺失字段。切换账号时不改写已有签名;`cli-node` 仍按现有登录 / fallback 逻辑清理签名块。
长度够的签名原样转发,由 Anthropic 验。5.5 的非 dummy 签名在非保留路径原样转发,不按 24 字符预过滤;保留路径仍要求不少于 24。签名是 opaque 加密内容,当前模型 / 账户 / 会话前缀不匹配时可能被丢弃或返回 400 `Invalid \`signature\` in \`thinking\` block`。网关不伪造真实签名,也不给缺签名的块补空字段。切换账号时不改写已有签名;`cli-node` 仍按现有登录 / fallback 逻辑清理签名块。

`routing.failover.signature_repair=true`(设置页「探测 → 签名修复」)才走 `signature_repairable` 的 `repair-and-retry`(剥 thinking 历史 + 删 `body.thinking` 后重试一次),客户端只看到 200。关闭时包括 cli-hop 在内都原样透传 400;`cli-node` 自身行为不受该面板开关改变。

`routing.compatibility.thinking_display` 默认 `summarized`(设置 → 协议 → thinking 显示;缺省即开,显式 `passthrough` 关闭)。只改已经打开的 thinking(`adaptive` / `enabled`):`omitted`、`updates`,以及 Opus 4.7+、5 系列、Haiku 5.5、Fable、Mythos 上没写 display 的请求,出站改成 `summarized`。其它值、`disabled`、没开 thinking 不动。改写在 beta 计算之前,所以被改掉的 `updates` 不再带 `thinking-display-updates-2026-08-18`。`passthrough` 时 `updates` 仍只在出站 beta 声明该 token 时保留,否则降成 `omitted`。分类器请求不经过这条出站改写。

## 健康探测缓存

`routing.health_probe`:
Expand Down
12 changes: 12 additions & 0 deletions src/lib/admin/panel-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* { ok: false, error: { type, code, message, ... } }
*/

import { THINKING_DISPLAY_MODES } from '../protocol/thinking.mjs'
import { readSlotProcessStatus } from '../vm/slot-process-status.mjs'
import os from 'node:os'
import path from 'node:path'
Expand Down Expand Up @@ -474,6 +475,13 @@ export function validatePersonaRoutingPatch(body = {}) {
if (Object.prototype.hasOwnProperty.call(compat, 'cch_enabled') && typeof compat.cch_enabled !== 'boolean') {
problems.push('cch_enabled 必须是布尔')
}
if (
compat.thinking_display != null &&
compat.thinking_display !== '' &&
!THINKING_DISPLAY_MODES.includes(String(compat.thinking_display).trim().toLowerCase())
) {
problems.push(`thinking_display 必须是 ${THINKING_DISPLAY_MODES.join(' / ')},收到 ${compat.thinking_display}`)
}
if (compat.cache_breakpoints != null) {
const bp = compat.cache_breakpoints
if (typeof bp !== 'object' || Array.isArray(bp)) {
Expand Down Expand Up @@ -1394,6 +1402,10 @@ export function buildUsage({ accountQuota, cfg, requestLog = null }) {
function presentCompatibility(compat) {
const out = compat && typeof compat === 'object' && !Array.isArray(compat) ? { ...compat } : {}
out.cch_enabled = out.cch_enabled !== false
const display = String(out.thinking_display ?? '')
.trim()
.toLowerCase()
out.thinking_display = display === 'passthrough' ? 'passthrough' : 'summarized'
return out
}

Expand Down
14 changes: 8 additions & 6 deletions src/lib/protocol/anthropic-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,11 @@ export function alignSamplingWithThinking(body = {}) {
* Opus / Sonnet / Haiku 5.5 follow sub2api: one invalid block drops the whole
* signed chain, including later valid thinking and redacted_thinking. Turns
* left with no content are dropped. redacted_thinking stays when it carries
* data on 5.5, otherwise when its signature is usable. The preserve path is
* unchanged.
* data on 5.5, otherwise when its signature is usable. The preserve path
* (cli-hop first hop, default HTTP hop) skips the chain rule and keeps a
* signed block as-is, empty text and redacted_thinking included. A thinking
* block without a usable signature (missing, empty, shorter than 24, dummy)
* is dropped instead of forwarded with an empty signature.
*/
export function stripInvalidThinkingBlocks(body = {}, { preserveThinkingBlocks = false } = {}) {
if (!Array.isArray(body.messages)) return body
Expand All @@ -194,12 +197,11 @@ export function stripInvalidThinkingBlocks(body = {}, { preserveThinkingBlocks =
if ((keepSigned || preserveThinkingBlocks) && message.role === 'assistant') {
// Omitted thinking keeps empty text; redacted thinking keeps encrypted data.
if (preserveThinkingBlocks) {
if (type === 'thinking' && !Object.hasOwn(block, 'signature')) {
if (type === 'redacted_thinking' || hasUsableThinkingSignature(block.signature)) {
content.push(block)
} else {
changed = true
filteredThis = true
content.push({ ...block, signature: '' })
} else {
content.push(block)
}
continue
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/protocol/handle-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,7 @@ export function createHandleProtocol(deps) {
vmId: selected.vmId,
epoch: attemptStartedAt,
mode: sessionMode,
routing: routingNow,
clientDiscriminator,
firstUserText,
firstUserIdentity,
Expand Down
18 changes: 15 additions & 3 deletions src/lib/protocol/outbound-attempt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import {
modelSupportsMidConversationSystem,
} from './anthropic-policy.mjs'
import { liftMidConversationSystemMessages } from './sanitize.mjs'
import { ensureUnofficialAdaptiveThinking, ensureUnofficialEffortHigh, normalizeThinkingForModel } from './thinking.mjs'
import {
applyThinkingDisplay,
ensureUnofficialAdaptiveThinking,
ensureUnofficialEffortHigh,
normalizeThinkingForModel,
thinkingDisplayMode,
} from './thinking.mjs'
import { ensureOutputConfigSchema } from './request-rectifier.mjs'
import {
applyCrsIdentityReplace,
Expand Down Expand Up @@ -334,6 +340,7 @@ export function prepareOutboundHeaders(reqHeaders, homeDir, identity, model, { c

/** Body + headers after the context_management ↔ context-management beta gate. */
export function prepareOutboundEnvelope({
requestContext = null,
canonicalBody,
inbound = {},
identity,
Expand Down Expand Up @@ -368,6 +375,7 @@ export function prepareOutboundEnvelope({
want1m,
} = {}) {
const prepared = prepareOutboundAttempt({
requestContext,
canonicalBody,
inbound,
identity,
Expand Down Expand Up @@ -421,11 +429,15 @@ export function prepareOutboundEnvelope({
const beta = ensureFastModeBeta(headers['anthropic-beta'] || '', prepared.body)
if (beta) headers['anthropic-beta'] = beta
}
const displayed =
requestContext?.purpose === 'auto_mode_classifier'
? prepared.body
: applyThinkingDisplay(prepared.body, thinkingDisplayMode(routing))
headers['anthropic-beta'] = withRequestProtocolBetas(
String(headers['anthropic-beta'] || '').split(','),
prepared.body,
displayed,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Classifier hops still rewrite thinking display

High Severity

Classifier requests still run through applyThinkingDisplay. prepareOutboundAttempt and prepareCliHopBody stop at prepareClassifierBody, but the HTTP envelope and worker finalize always rewrite hidden or unset display to summarized afterward. Auto-mode classifiers on models that hide thinking by default can then emit summaries instead of the expected tool or XML verdict.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c452238. Configure here.

).join(',')
const body = sealClaudeCodeCch(sanitizeAnthropicBodyForBetaTokens(prepared.body, headers?.['anthropic-beta'] || ''), {
const body = sealClaudeCodeCch(sanitizeAnthropicBodyForBetaTokens(displayed, headers?.['anthropic-beta'] || ''), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP hop ignores passthrough setting

High Severity

Default HTTP hop never passes routing into prepareOutboundEnvelope, so thinkingDisplayMode always falls back to summarized. The protocol-page passthrough switch therefore cannot keep caller omitted/updates on that path. A later worker pass cannot restore the original display once the body is already rewritten.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c452238. Configure here.

enabled: routing?.compatibility?.cch_enabled !== false,
})
return { body, headers, toolNames: prepared.toolNames }
Expand Down
57 changes: 57 additions & 0 deletions src/lib/protocol/thinking.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import { anthropicContentIsEmpty, mergeConsecutiveAnthropicMessages, messageHasT

export { clampEnabledThinkingBudget, MIN_THINKING_BUDGET }

/** `routing.compatibility.thinking_display`:缺省 summarized,显式 passthrough 才保留调用方的值。 */
export const THINKING_DISPLAY_MODES = ['summarized', 'passthrough']

export function thinkingDisplayMode(routing) {
const raw = String(routing?.compatibility?.thinking_display ?? '')
.trim()
.toLowerCase()
return raw === 'passthrough' ? 'passthrough' : 'summarized'
}

/**
* OpenAI reasoning <-> Claude thinking mapping + model-aware normalize.
* Official (Aug 2026):
Expand Down Expand Up @@ -154,6 +164,53 @@ export function downgradeUngatedThinkingDisplay(body = {}, anthropicBetaHeader =
return { ...body, thinking: { ...thinking, display: OFFICIAL_THINKING_DISPLAY } }
}

const HIDDEN_THINKING_DISPLAYS = new Set(['omitted', 'updates'])

function bareModel(model) {
return (
String(model || '')
.toLowerCase()
.split('[')[0]
.split('/')
.filter(Boolean)
.pop() || ''
)
}

/** Opus 4.7+, the 5 series, Haiku 5.5, Fable and Mythos return empty thinking unless asked. */
export function modelHidesThinkingByDefault(model) {
const m = bareModel(model)
if (!m) return false
if (/^claude-haiku-5(?:[-.]|$)/.test(m)) return true
if (/^claude-opus-4[-.][7-9](?:[-.]|$)/.test(m)) return true
if (/^claude-(?:opus|sonnet|fable|mythos)-5(?:[-.]|$)/.test(m)) return true
return /(?:^|-)(?:fable|mythos)(?:-|$)/.test(m)
}

/**
* `summarized` (the default) asks for a readable summary whenever the caller
* turned thinking on but hid it: explicit `omitted` or `updates`, or no display
* on a model whose default is `omitted`. Other values, `disabled`, and requests
* without thinking stay untouched, so a later fill can still write `omitted`.
* `passthrough` sends the caller's choice. Never mutates the input.
*/
export function applyThinkingDisplay(body, mode) {
if (mode !== 'summarized' || !body || typeof body !== 'object') return body
const thinking = body.thinking
if (!thinking || typeof thinking !== 'object') return body
const type = String(thinking.type || '')
.trim()
.toLowerCase()
if (type !== 'adaptive' && type !== 'enabled') return body
const display = String(thinking.display ?? '')
.trim()
.toLowerCase()
const hidden = HIDDEN_THINKING_DISPLAYS.has(display)
const unset = !display && modelHidesThinkingByDefault(body.model)
if (!hidden && !unset) return body
return { ...body, thinking: { ...thinking, display: 'summarized' } }
}

function thinkingNeedsOfficialDisplay(thinking) {
if (!thinking || typeof thinking !== 'object') return false
const type = String(thinking.type || '').toLowerCase()
Expand Down
33 changes: 29 additions & 4 deletions src/lib/transport/go-worker-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path'
import { prepareOutboundHeaders } from '../protocol/outbound-attempt.mjs'
import { sanitizeAnthropicBodyForBetaTokens } from '../protocol/anthropic-policy.mjs'
import { withRequestProtocolBetas } from '../protocol/claude-code-betas.mjs'
import { applyThinkingDisplay, thinkingDisplayMode } from '../protocol/thinking.mjs'
import { sealClaudeCodeCch } from '../identity/cch.mjs'
import { credentialModeFromOauth } from '../oauth/credential-mode.mjs'
import { isCrsMock, writeCrsTrace, mockCrsPayload, emitMockSse } from './crs-mock.mjs'
Expand Down Expand Up @@ -429,18 +430,31 @@ function dumpSessionEnvelope(envelope) {
* body on the VM's HTTP betas would lift every role=system turn into system[],
* so system grows each turn and no cached prefix is ever read.
*/
export function finalizeWorkerPayload({ body, reqHeaders, exec, identity, routing, want1m = false, cliHop = false }) {
export function finalizeWorkerPayload({
body,
reqHeaders,
exec,
identity,
routing,
want1m = false,
cliHop = false,
requestContext = null,
} = {}) {
const model = body?.model || ''
const credMode = credentialModeFromOauth(exec?.vm?.claude || {})
const headers = prepareOutboundHeaders(reqHeaders, exec?.homeDir, identity, model, {
credentialMode: credMode,
want1m: want1m === true,
})
const betas = withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), body)
const displayed =
requestContext?.purpose === 'auto_mode_classifier' ? body : applyThinkingDisplay(body, thinkingDisplayMode(routing))
const betas = withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), displayed)
headers['anthropic-beta'] = betas.join(',')
// Envelope headers are discarded; the existing MessageRequest.betas channel
// carries request gates to the native CLI, which builds the actual HTTP wire.
const gated = cliHop ? { ...body, betas } : sanitizeAnthropicBodyForBetaTokens(body, headers['anthropic-beta'])
const gated = cliHop
? { ...displayed, betas }
: sanitizeAnthropicBodyForBetaTokens(displayed, headers['anthropic-beta'])
return {
headers,
body: sealClaudeCodeCch(gated, { enabled: routing?.compatibility?.cch_enabled !== false, seal: !cliHop }),
Expand All @@ -461,7 +475,16 @@ function workerEnvelope({
preserveCacheBreakpoints = null,
cliHop = false,
} = {}) {
const finalized = finalizeWorkerPayload({ body, reqHeaders, exec, identity, routing, want1m, cliHop })
const finalized = finalizeWorkerPayload({
body,
reqHeaders,
exec,
identity,
routing,
want1m,
cliHop,
requestContext,
})
const envelope = {
...(requestContext ? { request_context: requestContext } : {}),
body: finalized.body,
Expand Down Expand Up @@ -509,6 +532,7 @@ export async function callGoWorker({
routing,
want1m,
cliHop,
requestContext,
})
writeCrsTrace({ body: outboundBody, headers, stream: false })
const mock = mockCrsPayload({ scenario: mockScenario(exec) })
Expand Down Expand Up @@ -613,6 +637,7 @@ export async function streamGoWorker({
routing,
want1m,
cliHop,
requestContext,
})
writeCrsTrace({ body: outboundBody, headers, stream: true })
const scenario = mockScenario(exec)
Expand Down
9 changes: 4 additions & 5 deletions test/e2e/auto-mode.e2e.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -694,19 +694,19 @@ test('actual kernel+CLI ELF keeps per-message effort and top-level high', { time
)
}
})
test('actual kernel+CLI ELF preserves mixed thinking signature blocks on final wire', { timeout: 30000 }, async (t) => {
test('actual kernel+CLI ELF drops thinking without a usable signature on final wire', { timeout: 30000 }, async (t) => {
const h = await harness(t)
const result = await h.send({
model: 'claude-opus-5-5',
max_tokens: 256,
thinking: { type: 'adaptive' },
system: [{ type: 'text', text: 'Preserve the prior thinking blocks exactly.' }],
system: [{ type: 'text', text: 'Drop thinking blocks that have no usable signature.' }],
messages: [
{ role: 'user', content: 'Continue the prior turn.' },
{
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature' },
{ type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature-0123456789' },
{ type: 'thinking', thinking: 'missing signature' },
{ type: 'thinking', thinking: '', signature: 'empty-thinking-signature' },
{ type: 'redacted_thinking', data: 'redacted-thinking' },
Expand All @@ -720,8 +720,7 @@ test('actual kernel+CLI ELF preserves mixed thinking signature blocks on final w
const wireMessage = h.captured.at(-1).messages.find((message) => message.role === 'assistant')
assert.ok(wireMessage)
assert.deepEqual(wireMessage.content, [
{ type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature' },
{ type: 'thinking', thinking: 'missing signature', signature: '' },
{ type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature-0123456789' },
{ type: 'thinking', thinking: '', signature: 'empty-thinking-signature' },
{ type: 'redacted_thinking', data: 'redacted-thinking' },
{ type: 'text', text: 'previous answer' },
Expand Down
Loading
Loading