From c452238ffd47b6153ddbbfb0136ac629abdfb594 Mon Sep 17 00:00:00 2001 From: ted Date: Mon, 19 Oct 2026 15:18:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(protocol):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=88=A0=E9=99=A4=E6=97=A0=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E7=9A=84=20thinking=EF=BC=8C=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=A1=B5=E9=BB=98=E8=AE=A4=E5=8F=AF=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli-hop 首跳和默认 HTTP hop 不再给缺签名的 thinking 补空字段:缺失、空、短于 24、dummy 的删掉,签名可用的留。5.5 的非保留路径仍接受短的非 dummy 签名。协议页新增 thinking 显示,默认 summarized,显式 passthrough 才保留调用方的 display。 --- CHANGELOG.md | 3 + docs/PROTOCOL.md | 8 ++- src/lib/admin/panel-api.mjs | 12 ++++ src/lib/protocol/anthropic-policy.mjs | 14 +++-- src/lib/protocol/outbound-attempt.mjs | 15 +++-- src/lib/protocol/thinking.mjs | 58 +++++++++++++++++++ src/lib/transport/go-worker-client.mjs | 6 +- test/e2e/auto-mode.e2e.test.mjs | 9 ++- test/unit/anthropic-policy.test.mjs | 18 +++--- test/unit/cli-hop-body.test.mjs | 10 ++-- test/unit/fast-mode.test.mjs | 26 +++++++++ test/unit/go-worker-client.test.mjs | 15 ++--- test/unit/persona-template.test.mjs | 6 ++ test/unit/quota-tiers.test.mjs | 13 +++++ web/src/features/settings/index.tsx | 2 + .../features/settings/protocol-authority.ts | 7 +++ web/src/features/settings/protocol-pane.tsx | 28 ++++++++- 17 files changed, 208 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca99d6956..f16de4cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 83a1b37d7..345913332 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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`: diff --git a/src/lib/admin/panel-api.mjs b/src/lib/admin/panel-api.mjs index 76c8cea1e..1351ee257 100644 --- a/src/lib/admin/panel-api.mjs +++ b/src/lib/admin/panel-api.mjs @@ -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' @@ -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)) { @@ -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 } diff --git a/src/lib/protocol/anthropic-policy.mjs b/src/lib/protocol/anthropic-policy.mjs index 717ef4d5c..f335dfb7c 100644 --- a/src/lib/protocol/anthropic-policy.mjs +++ b/src/lib/protocol/anthropic-policy.mjs @@ -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 @@ -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 } diff --git a/src/lib/protocol/outbound-attempt.mjs b/src/lib/protocol/outbound-attempt.mjs index 249b16c95..8f7e4f4d7 100644 --- a/src/lib/protocol/outbound-attempt.mjs +++ b/src/lib/protocol/outbound-attempt.mjs @@ -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, @@ -421,12 +427,13 @@ export function prepareOutboundEnvelope({ const beta = ensureFastModeBeta(headers['anthropic-beta'] || '', prepared.body) if (beta) headers['anthropic-beta'] = beta } + const displayed = applyThinkingDisplay(prepared.body, thinkingDisplayMode(routing)) headers['anthropic-beta'] = withRequestProtocolBetas( String(headers['anthropic-beta'] || '').split(','), - prepared.body, + displayed, ).join(',') - const body = sealClaudeCodeCch(sanitizeAnthropicBodyForBetaTokens(prepared.body, headers?.['anthropic-beta'] || ''), { + const body = sealClaudeCodeCch(sanitizeAnthropicBodyForBetaTokens(displayed, headers?.['anthropic-beta'] || ''), { enabled: routing?.compatibility?.cch_enabled !== false, }) return { body, headers, toolNames: prepared.toolNames } -} +} \ No newline at end of file diff --git a/src/lib/protocol/thinking.mjs b/src/lib/protocol/thinking.mjs index 6ae89707d..26390b903 100644 --- a/src/lib/protocol/thinking.mjs +++ b/src/lib/protocol/thinking.mjs @@ -1,3 +1,4 @@ + import { BETA_THINKING_DISPLAY_UPDATES } from './claude-code-betas.mjs' import { @@ -11,6 +12,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): @@ -154,6 +165,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() diff --git a/src/lib/transport/go-worker-client.mjs b/src/lib/transport/go-worker-client.mjs index f32c0f0af..aca5378ae 100644 --- a/src/lib/transport/go-worker-client.mjs +++ b/src/lib/transport/go-worker-client.mjs @@ -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' @@ -436,11 +437,12 @@ export function finalizeWorkerPayload({ body, reqHeaders, exec, identity, routin credentialMode: credMode, want1m: want1m === true, }) - const betas = withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), body) + const displayed = 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 }), diff --git a/test/e2e/auto-mode.e2e.test.mjs b/test/e2e/auto-mode.e2e.test.mjs index b8d78b27c..1b2a07269 100644 --- a/test/e2e/auto-mode.e2e.test.mjs +++ b/test/e2e/auto-mode.e2e.test.mjs @@ -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' }, @@ -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' }, diff --git a/test/unit/anthropic-policy.test.mjs b/test/unit/anthropic-policy.test.mjs index 1b8058db7..312b751f8 100644 --- a/test/unit/anthropic-policy.test.mjs +++ b/test/unit/anthropic-policy.test.mjs @@ -258,7 +258,7 @@ test('5.5 keeps a lone redacted block and a short non-dummy signature', () => { } }) -test('5.5 preserve path still keeps an invalid signed chain', () => { +test('preserve path drops thinking without a usable signature and keeps the rest', () => { const body = stripInvalidThinkingBlocks( { model: 'claude-opus-5-5', @@ -277,10 +277,11 @@ test('5.5 preserve path still keeps an invalid signed chain', () => { }, { preserveThinkingBlocks: true }, ) - assert.deepEqual( - body.messages[0].content.map((block) => block.type), - ['thinking', 'thinking', 'redacted_thinking', 'text'], - ) + assert.deepEqual(body.messages[0].content, [ + { type: 'thinking', thinking: 'later', signature: 'sig_real_1234567890abcdef' }, + { type: 'redacted_thinking', data: 'encrypted' }, + { type: 'text', text: 'answer' }, + ]) }) test('an untyped thinking block breaks a valid 5.5 chain and is dropped on older models', () => { const messages = [ @@ -310,7 +311,7 @@ test('an untyped thinking block breaks a valid 5.5 chain and is dropped on older ) }) -test('panel preservation creates a missing thinking signature field', () => { +test('panel preservation drops a thinking block that has no signature field', () => { const body = prepareAnthropicRequest( { model: 'claude-opus-5', @@ -327,10 +328,7 @@ test('panel preservation creates a missing thinking signature field', () => { }, { preserveThinkingBlocks: true }, ) - assert.deepEqual(body.messages[0].content, [ - { type: 'thinking', thinking: 'opaque', signature: '' }, - { type: 'text', text: 'answer' }, - ]) + assert.deepEqual(body.messages[0].content, [{ type: 'text', text: 'answer' }]) }) test('existing context_management is not overwritten', () => { diff --git a/test/unit/cli-hop-body.test.mjs b/test/unit/cli-hop-body.test.mjs index 27caeb724..fc8a38c89 100644 --- a/test/unit/cli-hop-body.test.mjs +++ b/test/unit/cli-hop-body.test.mjs @@ -210,7 +210,7 @@ test('prepareCliHopBody strips unsigned dummy and short thinking history', () => { type: 'text', text: 'hello' }, ]) }) -test('prepareCliHopBody keeps existing signatures and fills missing ones', () => { +test('prepareCliHopBody drops thinking without a usable signature on the preserve path', () => { const body = prepareCliHopBody( { model: 'claude-sonnet-5', @@ -220,8 +220,10 @@ test('prepareCliHopBody keeps existing signatures and fills missing ones', () => { role: 'assistant', content: [ - { type: 'thinking', thinking: 'existing', signature: 'abc' }, + { type: 'thinking', thinking: 'existing', signature: 'sig_real_1234567890abcdef' }, + { type: 'thinking', thinking: 'short', signature: 'abc' }, { type: 'thinking', thinking: 'missing' }, + { type: 'redacted_thinking', data: '' }, { type: 'text', text: 'hello' }, ], }, @@ -230,8 +232,8 @@ test('prepareCliHopBody keeps existing signatures and fills missing ones', () => { preserveThinkingBlocks: true }, ) assert.deepEqual(body.messages[1].content, [ - { type: 'thinking', thinking: 'existing', signature: 'abc' }, - { type: 'thinking', thinking: 'missing', signature: '' }, + { type: 'thinking', thinking: 'existing', signature: 'sig_real_1234567890abcdef' }, + { type: 'redacted_thinking', data: '' }, { type: 'text', text: 'hello', cache_control: { type: 'ephemeral', ttl: '1h' } }, ]) }) diff --git a/test/unit/fast-mode.test.mjs b/test/unit/fast-mode.test.mjs index 4aedbb772..c8ff91d5f 100644 --- a/test/unit/fast-mode.test.mjs +++ b/test/unit/fast-mode.test.mjs @@ -77,3 +77,29 @@ test('outbound envelope drops speed for a model without fast mode', () => { assert.equal('speed' in r.body, false) assert.equal(r.headers['anthropic-beta'].includes(FAST_MODE_BETA), false) }) + +test('outbound envelope summarizes a hidden thinking display unless passthrough', () => { + const body = { + model: 'claude-opus-5', + max_tokens: 100, + thinking: { type: 'adaptive', display: 'omitted' }, + messages: [{ role: 'user', content: 'hi' }], + } + const summarized = prepareOutboundEnvelope({ + identity: IDENTITY, + canonicalBody: body, + inbound: body, + unofficial: true, + stream: true, + }) + assert.equal(summarized.body.thinking.display, 'summarized') + const passed = prepareOutboundEnvelope({ + identity: IDENTITY, + canonicalBody: body, + inbound: body, + unofficial: true, + stream: true, + routing: { compatibility: { thinking_display: 'passthrough' } }, + }) + assert.equal(passed.body.thinking.display, 'omitted') +}) diff --git a/test/unit/go-worker-client.test.mjs b/test/unit/go-worker-client.test.mjs index 7a600862e..43a1a32e1 100644 --- a/test/unit/go-worker-client.test.mjs +++ b/test/unit/go-worker-client.test.mjs @@ -164,7 +164,7 @@ test('cli-hop envelope keeps role=system turns that the VM betas do not declare' assert.deepEqual(out.body.system, body.system) }) -test('setup-token cli-hop preserves updates with its beta and isolates later requests', () => { +test('setup-token cli-hop summarizes hidden thinking by default and keeps updates when passthrough', () => { const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } } const updates = finalizeWorkerPayload({ body: { @@ -178,22 +178,23 @@ test('setup-token cli-hop preserves updates with its beta and isolates later req cliHop: true, }) assert.equal(updates.body.thinking.type, 'adaptive') - assert.equal(updates.body.thinking.display, 'updates') - assert.ok(updates.body.betas.includes('thinking-display-updates-2026-08-18')) + assert.equal(updates.body.thinking.display, 'summarized') + assert.ok(!updates.body.betas.includes('thinking-display-updates-2026-08-18')) assert.ok(!updates.body.betas.includes('claude-code-20250219')) - const summarized = finalizeWorkerPayload({ + const passed = finalizeWorkerPayload({ body: { model: 'claude-opus-5-5', - thinking: { type: 'adaptive', display: 'summarized' }, + thinking: { type: 'adaptive', display: 'updates' }, messages: [{ role: 'user', content: 'hi' }], }, reqHeaders: {}, exec, identity: null, + routing: { compatibility: { thinking_display: 'passthrough' } }, cliHop: true, }) - assert.equal(summarized.body.thinking.display, 'summarized') - assert.ok(!summarized.body.betas.includes('thinking-display-updates-2026-08-18')) + assert.equal(passed.body.thinking.display, 'updates') + assert.ok(passed.body.betas.includes('thinking-display-updates-2026-08-18')) }) const unix = process.platform !== 'win32' diff --git a/test/unit/persona-template.test.mjs b/test/unit/persona-template.test.mjs index e400de602..ed144754a 100644 --- a/test/unit/persona-template.test.mjs +++ b/test/unit/persona-template.test.mjs @@ -441,6 +441,12 @@ test('PUT routing accepts only 5m and 1h cache TTL values', () => { assert.match(validatePersonaRoutingPatch({ compatibility: { cache_ttl: '60m' } })[0], /5m \/ 1h/) }) +test('PUT routing accepts summarized and passthrough thinking display', () => { + assert.deepEqual(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'summarized' } }), []) + assert.deepEqual(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'passthrough' } }), []) + assert.match(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'omitted' } })[0], /summarized \/ passthrough/) +}) + test('legacy routing without persona_preset keeps the old rewrite path', () => { withRoutingFile({ persona_inject: 'rewrite', persona_park: false }, (file) => { const out = applyCrsUnofficialPersona( diff --git a/test/unit/quota-tiers.test.mjs b/test/unit/quota-tiers.test.mjs index 1351a71c8..bf4a33147 100644 --- a/test/unit/quota-tiers.test.mjs +++ b/test/unit/quota-tiers.test.mjs @@ -86,6 +86,19 @@ test('buildRouting returns normalized vm defaults', () => { assert.equal(data.vm.allow_tcg, false) }) +test('buildRouting defaults a missing thinking display to summarized', () => { + const data = buildRouting({ + routingConfig: { sticky: {}, quota: {}, concurrency: {} }, + stickyRouter: { stats: () => ({ active_sessions: 0 }) }, + }).data + assert.equal(data.compatibility.thinking_display, 'summarized') + const pinned = buildRouting({ + routingConfig: { compatibility: { thinking_display: 'passthrough' } }, + stickyRouter: { stats: () => ({ active_sessions: 0 }) }, + }).data + assert.equal(pinned.compatibility.thinking_display, 'passthrough') +}) + test('buildRouting exposes effective Codex routing to the panel', () => { const data = buildRouting({ routingConfig: { codex: { clients: { official_codex: 'allow' } } }, diff --git a/web/src/features/settings/index.tsx b/web/src/features/settings/index.tsx index b9788fd36..5cc27aa0c 100644 --- a/web/src/features/settings/index.tsx +++ b/web/src/features/settings/index.tsx @@ -40,6 +40,7 @@ import { cchEnabledFromCompat, inheritProtocolOnSlots, sessionIdleTtlFromInference, + thinkingDisplayFromCompat, } from '@/features/settings/protocol-authority' import { notifyQueryOptions, @@ -112,6 +113,7 @@ export function SettingsPage() { claude_code_only: compat.claude_code_only === true, official_cc_passthrough: compat.official_cc_passthrough === true, cch_enabled: cchEnabledFromCompat(compat), + thinking_display: thinkingDisplayFromCompat(compat), persona_rules: rules, } } diff --git a/web/src/features/settings/protocol-authority.ts b/web/src/features/settings/protocol-authority.ts index 2fcf00016..6828649c0 100644 --- a/web/src/features/settings/protocol-authority.ts +++ b/web/src/features/settings/protocol-authority.ts @@ -24,6 +24,13 @@ export function cchEnabledFromCompat( return compat?.cch_enabled !== false } +/** `routing.compatibility.thinking_display`:缺省 summarized,显式 passthrough 才关。 */ +export function thinkingDisplayFromCompat( + compat: Record | undefined +): 'summarized' | 'passthrough' { + return compat?.thinking_display === 'passthrough' ? 'passthrough' : 'summarized' +} + /** `routing.inference.session_idle_ttl_seconds`:kernel 原生 sticky 槽空闲,默认 600。 */ export function sessionIdleTtlFromInference( inference: Record | undefined diff --git a/web/src/features/settings/protocol-pane.tsx b/web/src/features/settings/protocol-pane.tsx index a8313a3df..1e38d8cf7 100644 --- a/web/src/features/settings/protocol-pane.tsx +++ b/web/src/features/settings/protocol-pane.tsx @@ -3,7 +3,7 @@ import { SESSION_IDLE_TTL_SECONDS_MAX, SESSION_IDLE_TTL_SECONDS_MIN, } from '@/types/panel-routing' -import { Check, Clock3, Database, Hash, ShieldCheck } from 'lucide-react' +import { Brain, Check, Clock3, Database, Hash, ShieldCheck } from 'lucide-react' import { cacheTtlFromCompat, detectProxiedOfficialCcFromCompat, @@ -15,6 +15,7 @@ import { SettingRow } from '@/components/setting-row' import { cchEnabledFromCompat, sessionIdleTtlFromInference, + thinkingDisplayFromCompat, } from './protocol-authority' import { ChipGroup, PaneSection, StepperSetting } from './scheduler-controls' @@ -161,6 +162,31 @@ export function ProtocolPane({ TTL。已有会话锁定值不变,官方透传仍保留调用方断点及缺失 TTL。

+ + + + onCompatChange({ + ...compat, + thinking_display: on ? 'summarized' : 'passthrough', + }) + } + aria-label='隐藏的 thinking 改为返回摘要' + /> + + Date: Mon, 19 Oct 2026 07:27:32 +0000 Subject: [PATCH 2/3] fix: skip thinking_display rewrite on classifier outbound paths Wire requestContext through prepareOutboundEnvelope, bypass applyThinkingDisplay for auto_mode_classifier in envelope and worker finalize, pass routing into HTTP hop prepareOutboundEnvelope, and add unit coverage. Run biome/prettier on CI files. Co-authored-by: ted --- src/lib/protocol/handle-protocol.mjs | 1 + src/lib/protocol/outbound-attempt.mjs | 9 +++-- src/lib/protocol/thinking.mjs | 1 - src/lib/transport/go-worker-client.mjs | 31 ++++++++++++++--- test/unit/auto-mode.test.mjs | 34 ++++++++++++++++++- test/unit/persona-template.test.mjs | 5 ++- .../features/settings/protocol-authority.ts | 4 ++- 7 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs index 907831f75..68e698084 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -1374,6 +1374,7 @@ export function createHandleProtocol(deps) { vmId: selected.vmId, epoch: attemptStartedAt, mode: sessionMode, + routing: routingNow, clientDiscriminator, firstUserText, firstUserIdentity, diff --git a/src/lib/protocol/outbound-attempt.mjs b/src/lib/protocol/outbound-attempt.mjs index 8f7e4f4d7..95326c71b 100644 --- a/src/lib/protocol/outbound-attempt.mjs +++ b/src/lib/protocol/outbound-attempt.mjs @@ -340,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, @@ -374,6 +375,7 @@ export function prepareOutboundEnvelope({ want1m, } = {}) { const prepared = prepareOutboundAttempt({ + requestContext, canonicalBody, inbound, identity, @@ -427,7 +429,10 @@ export function prepareOutboundEnvelope({ const beta = ensureFastModeBeta(headers['anthropic-beta'] || '', prepared.body) if (beta) headers['anthropic-beta'] = beta } - const displayed = applyThinkingDisplay(prepared.body, thinkingDisplayMode(routing)) + const displayed = + requestContext?.purpose === 'auto_mode_classifier' + ? prepared.body + : applyThinkingDisplay(prepared.body, thinkingDisplayMode(routing)) headers['anthropic-beta'] = withRequestProtocolBetas( String(headers['anthropic-beta'] || '').split(','), displayed, @@ -436,4 +441,4 @@ export function prepareOutboundEnvelope({ enabled: routing?.compatibility?.cch_enabled !== false, }) return { body, headers, toolNames: prepared.toolNames } -} \ No newline at end of file +} diff --git a/src/lib/protocol/thinking.mjs b/src/lib/protocol/thinking.mjs index 26390b903..558ada7ce 100644 --- a/src/lib/protocol/thinking.mjs +++ b/src/lib/protocol/thinking.mjs @@ -1,4 +1,3 @@ - import { BETA_THINKING_DISPLAY_UPDATES } from './claude-code-betas.mjs' import { diff --git a/src/lib/transport/go-worker-client.mjs b/src/lib/transport/go-worker-client.mjs index aca5378ae..5e90a7ea0 100644 --- a/src/lib/transport/go-worker-client.mjs +++ b/src/lib/transport/go-worker-client.mjs @@ -430,19 +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 displayed = applyThinkingDisplay(body, thinkingDisplayMode(routing)) + 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 ? { ...displayed, betas } : sanitizeAnthropicBodyForBetaTokens(displayed, 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 }), @@ -463,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, @@ -511,6 +532,7 @@ export async function callGoWorker({ routing, want1m, cliHop, + requestContext, }) writeCrsTrace({ body: outboundBody, headers, stream: false }) const mock = mockCrsPayload({ scenario: mockScenario(exec) }) @@ -615,6 +637,7 @@ export async function streamGoWorker({ routing, want1m, cliHop, + requestContext, }) writeCrsTrace({ body: outboundBody, headers, stream: true }) const scenario = mockScenario(exec) diff --git a/test/unit/auto-mode.test.mjs b/test/unit/auto-mode.test.mjs index 8a9431ae0..abb551a2f 100644 --- a/test/unit/auto-mode.test.mjs +++ b/test/unit/auto-mode.test.mjs @@ -1,7 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' import { classifierFixture } from '../fixtures/auto-mode.mjs' -import { prepareCliHopBody, prepareOutboundAttempt } from '../../src/lib/protocol/outbound-attempt.mjs' +import { + prepareCliHopBody, + prepareOutboundAttempt, + prepareOutboundEnvelope, +} from '../../src/lib/protocol/outbound-attempt.mjs' +import { finalizeWorkerPayload } from '../../src/lib/transport/go-worker-client.mjs' import { classifyClaudeRequestPurpose, prepareClassifierBody, @@ -30,6 +35,33 @@ test('classifier assembly retains caller fields across both outbound paths', () } }) +test('classifier outbound skips thinking_display rewrite on envelope and worker finalize', () => { + const input = { + ...classifierFixture({ model: 'claude-sonnet-5-5' }), + thinking: { type: 'adaptive', display: 'omitted' }, + } + const prepared = prepareClassifierBody(input) + const envelope = prepareOutboundEnvelope({ + requestContext: context, + canonicalBody: input, + inbound: input, + identity: {}, + unofficial: true, + stream: true, + }) + assert.equal(envelope.body.thinking.display, 'omitted') + const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } } + const worker = finalizeWorkerPayload({ + body: prepared, + reqHeaders: {}, + exec, + identity: null, + requestContext: context, + cliHop: true, + }) + assert.equal(worker.body.thinking.display, 'omitted') +}) + for (const verdict of ['block', 'severity']) for (const stage of ['xml_s1', 'fast', 'xml_s2']) test(`recognizes ${verdict} XML ${stage} from the wire contract`, () => { diff --git a/test/unit/persona-template.test.mjs b/test/unit/persona-template.test.mjs index ed144754a..05a9a04d1 100644 --- a/test/unit/persona-template.test.mjs +++ b/test/unit/persona-template.test.mjs @@ -444,7 +444,10 @@ test('PUT routing accepts only 5m and 1h cache TTL values', () => { test('PUT routing accepts summarized and passthrough thinking display', () => { assert.deepEqual(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'summarized' } }), []) assert.deepEqual(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'passthrough' } }), []) - assert.match(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'omitted' } })[0], /summarized \/ passthrough/) + assert.match( + validatePersonaRoutingPatch({ compatibility: { thinking_display: 'omitted' } })[0], + /summarized \/ passthrough/, + ) }) test('legacy routing without persona_preset keeps the old rewrite path', () => { diff --git a/web/src/features/settings/protocol-authority.ts b/web/src/features/settings/protocol-authority.ts index 6828649c0..45b1d94fd 100644 --- a/web/src/features/settings/protocol-authority.ts +++ b/web/src/features/settings/protocol-authority.ts @@ -28,7 +28,9 @@ export function cchEnabledFromCompat( export function thinkingDisplayFromCompat( compat: Record | undefined ): 'summarized' | 'passthrough' { - return compat?.thinking_display === 'passthrough' ? 'passthrough' : 'summarized' + return compat?.thinking_display === 'passthrough' + ? 'passthrough' + : 'summarized' } /** `routing.inference.session_idle_ttl_seconds`:kernel 原生 sticky 槽空闲,默认 600。 */ From 11c1ce5e12048000a9617679f71cd4fe1fc3ffba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:35:58 +0000 Subject: [PATCH 3/3] test: passthrough thinking_display in 2.1.281 capture parity check Keep unofficial envelope snapshot aligned with official omitted display without asserting against the gateway default summarized rewrite. Co-authored-by: ted --- test/unit/outbound-official-capture.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/outbound-official-capture.test.mjs b/test/unit/outbound-official-capture.test.mjs index ba0b5a25a..67f7d8ace 100644 --- a/test/unit/outbound-official-capture.test.mjs +++ b/test/unit/outbound-official-capture.test.mjs @@ -62,6 +62,8 @@ test('unofficial outbound envelope matches official 2.1.281 capture keys', () => stream: true, reqHeaders: headersIn, homeDir: dir, + // 2.1.281 wire used omitted; passthrough keeps this snapshot off the gateway summarized default. + routing: { compatibility: { thinking_display: 'passthrough' } }, }) const { headers, body } = envelope assert.equal(headers['user-agent'], 'claude-cli/2.1.281 (external, sdk-cli)')