diff --git a/CHANGELOG.md b/CHANGELOG.md index c7780f803..d2ae1d512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.3.183 — 2026-10-17 + +- Opus / Sonnet / Haiku 5.5 的出站过滤对齐 sub2api:历史里出现一块无效 thinking,整段 thinking 与 `redacted_thinking` 都拆掉,只留正文;拆完没有内容的轮删掉。没有 `type` 但带 `thinking` 字段的块也算无效。`redacted_thinking` 有 `data` 就留,否则看签名;非空且非 dummy 的签名不再按 24 字符丢弃。全部有效时不拆。cli-hop 首跳和默认 HTTP 保留路径不变。更早型号仍逐块过滤,`redacted_thinking` 只在签名达到 24 字符时留。 + +已部署机升级:更新 Node 控制面,重启一次 Node。无迁移、无新依赖,不必 `wrap-cli/sync`,不必重建槽位容器。不要覆盖 `routing.json`、`vms/`、`data/`、`.env`。 + ## 未发布 ## 1.3.182 — 2026-10-17 @@ -19,6 +25,7 @@ - 采集客户机 CPU、内存、磁盘、MAC、DMI、Shell 与工具链,写入槽位特征并展示于运维页;终端启动打印实时系统特征。 - 采集后同步 worker.json 遥测配置至本机或节点,sidecar 热加载,无需重启;官方初装自动开启遥测默认关闭。 + - 代理出口地理查询经 SOCKS5 时由代理端解析目标域名(查询路径内将 `socks5://` 规范为 `socks5h:`),避免依赖控制面本地 DNS;IPv4 与 IPv6 两阶段探测一致,不修改已保存代理 URI,HTTP(S) 代理与本地直连行为不变。(#482) - 修复 KVM / Docker 槽容器处于 `Restarting` 崩溃循环时被当成运行中:`inspectContainer()` 读取 `State.Restarting`,崩溃循环的容器视为未运行,启动和重载改为重建,不再 `already-running` 短路或对重启中的容器 `docker exec`。新建槽位容器前先 `docker pull` 浮动 tag(`kin-kvm-*` / `kin-os-*`),拉取失败才用本地镜像,旧 runner 镜像可随重建更新;`docker/kin-*/build.mjs --pull` / `--pull-only` 也会刷新已存在的镜像。`kin-kvm-entry` 降权前对容器内 `/dev/kvm` 节点执行 `chmod g+rw`,兼容宿主 `0600` 权限。(#484) diff --git a/VERSION b/VERSION index 6d9c0f637..4fcd74720 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.182 \ No newline at end of file +1.3.183 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 536ff5b55..648c9b6fe 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -111,12 +111,13 @@ Rikka / 客户端 `search_web`、`scrape_web` **不是** Anthropic 自带搜索 出站前 `stripInvalidThinkingBlocks` 保留可回放的历史思维块: 1. `thinking` 即使文本为空,只要有完整签名也保留;这是 `display: omitted` 的正常响应形态。 -2. `redacted_thinking` 只要带加密 `data` 就原样保留。 -3. 默认其它无签名、短签名或 dummy 签名仍过滤;面板保留路径会保留 thinking 块,并为缺少字段的 `thinking` 补空 `signature`,已有字段不改写,真实性仍由上游校验。 +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` 补空字段,已有字段不改写。 HTTP hop 与 cli-hop(`prepareCliHopBody`)共用内容预过滤,但职责不同。cli-hop 的首个出站 hop 保留已有 thinking 指纹块,并为缺字段的 thinking 补 `signature: ""`;只有签名修复重试的 `repaired` hop 才清除这些块。Node 清除 caller `cache_control` 后写入最后消息及 `messages.length>=4` 时倒数第二 user 的断点,TTL 用入站会话已 pin 值。native Claude Code CLI 每个 job 热读 slot `kernel.json` 做 persona 布局,自产 system/tools 标记必须沿用同一请求 TTL,不得再从面板独立决策。Rust kernel 只认证、转发和流式传输,不再重打 last。Node 出站 JSON 不是最终 wire。HTTP hop 仍可由 Node 按自己的路由策略整流缓存。 -长度够的签名原样转发,由 Anthropic 验。签名是 opaque 加密内容,当前模型 / 账户 / 会话前缀不匹配时可能被丢弃或返回 400 `Invalid \`signature\` in \`thinking\` block`。网关不伪造真实签名,只在面板 / cli-hop 保留路径补齐缺失字段。切换账号时不改写已有签名;`cli-node` 仍按现有登录 / fallback 逻辑清理签名块。 +长度够的签名原样转发,由 Anthropic 验。5.5 的非 dummy 签名也原样转发,不按 24 字符预过滤。签名是 opaque 加密内容,当前模型 / 账户 / 会话前缀不匹配时可能被丢弃或返回 400 `Invalid \`signature\` in \`thinking\` block`。网关不伪造真实签名,只在面板 / cli-hop 保留路径补齐缺失字段。切换账号时不改写已有签名;`cli-node` 仍按现有登录 / fallback 逻辑清理签名块。 `routing.failover.signature_repair=true`(设置页「探测 → 签名修复」)才走 `signature_repairable` 的 `repair-and-retry`(剥 thinking 历史 + 删 `body.thinking` 后重试一次),客户端只看到 200。关闭时包括 cli-hop 在内都原样透传 400;`cli-node` 自身行为不受该面板开关改变。 diff --git a/src/lib/protocol/anthropic-policy.mjs b/src/lib/protocol/anthropic-policy.mjs index db83eea59..717ef4d5c 100644 --- a/src/lib/protocol/anthropic-policy.mjs +++ b/src/lib/protocol/anthropic-policy.mjs @@ -14,7 +14,13 @@ import { ensureUnofficialEffortHigh, downgradeUngatedThinkingDisplay, } from './thinking.mjs' -import { applyMaxTokensCap, applyModelRequestRules, getCapabilities } from './model-policy.mjs' +import { + applyMaxTokensCap, + applyModelRequestRules, + getCapabilities, + isHaiku55Model, + isOpus55Model, +} from './model-policy.mjs' import { ensureOutputConfigSchema, rectifyUnofficialRequest } from './request-rectifier.mjs' import { DEFAULT_CACHE_TTL, applyCacheBreakpoints, stripCacheScopeFields } from './cache-ttl.mjs' import { normalizeImageContentBlocks } from './images.mjs' @@ -108,6 +114,43 @@ export function hasUsableThinkingSignature(signature) { if (!value || value.length < 24) return false return !DUMMY_THINKING_SIGNATURES.has(value) } +function signedThinkingModel(model = '') { + if (isOpus55Model(model) || isHaiku55Model(model)) return true + const bare = String(model || '') + .split('[')[0] + .split('/') + .filter(Boolean) + .pop() + return /^claude-sonnet-5(?:-5|\.5)(?=-|$)/i.test(bare || '') +} + +/** 5.5 accepts any non-dummy signature. Length is not a checksum. */ +function hasNonDummyThinkingSignature(signature) { + const value = String(signature || '').trim() + return value.length > 0 && !DUMMY_THINKING_SIGNATURES.has(value) +} + +function dropSignedThinkingChain(messages) { + return messages.map((message) => { + if (!Array.isArray(message?.content)) return message + let dropped = false + const content = [] + for (const block of message.content) { + const type = block?.type + if ( + type === 'thinking' || + type === 'redacted_thinking' || + (type == null && block && Object.hasOwn(block, 'thinking')) + ) { + dropped = true + continue + } + content.push(block) + } + if (!dropped) return message + return { ...message, content } + }) +} function thinkingModeEnabled(body) { const type = String(body?.thinking?.type || '').toLowerCase() @@ -127,11 +170,20 @@ export function alignSamplingWithThinking(body = {}) { return out } -/** Drop history thinking blocks Anthropic would reject (empty / unsigned / dummy). */ +/** + * Drop history thinking blocks Anthropic would reject. + * 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. + */ export function stripInvalidThinkingBlocks(body = {}, { preserveThinkingBlocks = false } = {}) { if (!Array.isArray(body.messages)) return body const keepSigned = thinkingModeEnabled(body) + const chainModel = !preserveThinkingBlocks && signedThinkingModel(body.model) let changed = false + let chainBroken = false const messages = body.messages.map((message) => { if (!Array.isArray(message?.content)) return message const content = [] @@ -151,25 +203,41 @@ export function stripInvalidThinkingBlocks(body = {}, { preserveThinkingBlocks = } continue } - if (type === 'redacted_thinking' && String(block.data || '').trim()) { + if (chainModel && chainBroken) { + changed = true + filteredThis = true + continue + } + if (type === 'redacted_thinking' && chainModel && String(block.data || '').trim()) { content.push(block) continue } - if (type === 'thinking' && hasUsableThinkingSignature(block.signature)) { + const signed = chainModel + ? hasNonDummyThinkingSignature(block.signature) + : hasUsableThinkingSignature(block.signature) + if (signed) { content.push(block) continue } + if (chainModel) chainBroken = true } changed = true filteredThis = true continue } + if (type == null && block && Object.hasOwn(block, 'thinking')) { + changed = true + filteredThis = true + if (chainModel) chainBroken = true + continue + } content.push(block) } return filteredThis ? { ...message, content } : message }) - if (!changed) return dropEmptySystemControlTurns(body) - const withoutEmpty = messages.filter( + const stripped = chainBroken ? dropSignedThinkingChain(messages) : messages + if (!changed && !chainBroken) return dropEmptySystemControlTurns(body) + const withoutEmpty = stripped.filter( (message) => !anthropicContentIsEmpty(message?.content) || messageHasTurnControls(message), ) return dropEmptySystemControlTurns({ ...body, messages: mergeConsecutiveAnthropicMessages(withoutEmpty) }) diff --git a/test/unit/anthropic-policy.test.mjs b/test/unit/anthropic-policy.test.mjs index 118627146..1b8058db7 100644 --- a/test/unit/anthropic-policy.test.mjs +++ b/test/unit/anthropic-policy.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { prepareAnthropicRequest, + stripInvalidThinkingBlocks, rewriteToolNames, restoreToolNames, restoreToolNamesInSSELine, @@ -124,7 +125,7 @@ test('unsigned and empty thinking blocks are stripped before hop', () => { { type: 'text', text: 'answer' }, ]) }) -test('preserves omitted signed and redacted thinking blocks', () => { +test('older models keep a long omitted signature and drop redacted thinking', () => { const body = prepareAnthropicRequest({ model: 'claude-opus-5', thinking: { type: 'adaptive' }, @@ -141,10 +142,173 @@ test('preserves omitted signed and redacted thinking blocks', () => { }) assert.deepEqual(body.messages[0].content, [ { type: 'thinking', thinking: '', signature: 'sig_omitted_12345678901234567890' }, - { type: 'redacted_thinking', data: 'encrypted-thinking' }, { type: 'text', text: 'answer' }, ]) }) +test('5.5 drops the whole signed chain after one invalid block', () => { + const messages = [ + { role: 'user', content: [{ type: 'text', text: 'a' }] }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'old', signature: '' }, + { type: 'text', text: 'answer' }, + ], + }, + { role: 'user', content: [{ type: 'text', text: 'b' }] }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'later', signature: 'sig_real_1234567890abcdef' }, + { type: 'redacted_thinking', data: 'encrypted' }, + { type: 'text', text: 'later answer' }, + ], + }, + { + role: 'assistant', + content: [{ type: 'thinking', thinking: 'only thought', signature: 'sig_real_1234567890abcdef' }], + }, + ] + for (const model of ['claude-opus-5-5', 'anthropic/claude-sonnet-5.5', 'claude-haiku-5-5']) { + const body = stripInvalidThinkingBlocks({ + model, + thinking: { type: 'adaptive' }, + messages, + }) + assert.deepEqual( + body.messages.map((message) => message.content.map((block) => block.text || block.type)), + [['a'], ['answer'], ['b'], ['later answer']], + model, + ) + } + const older = stripInvalidThinkingBlocks({ + model: 'claude-opus-5', + thinking: { type: 'adaptive' }, + messages, + }) + assert.deepEqual( + older.messages[3].content.map((block) => block.type), + ['thinking', 'text', 'thinking'], + ) +}) + +test('5.5 keeps a lone redacted block and a short non-dummy signature', () => { + const kept = stripInvalidThinkingBlocks({ + model: 'claude-sonnet-5-5', + thinking: { type: 'adaptive' }, + messages: [ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: 'signed' }, + { type: 'redacted_thinking', data: 'encrypted' }, + { type: 'text', text: 'ok' }, + ], + }, + ], + }) + assert.deepEqual( + kept.messages[0].content.map((block) => block.type), + ['thinking', 'redacted_thinking', 'text'], + ) + const dropped = stripInvalidThinkingBlocks({ + model: 'claude-opus-5-5', + thinking: { type: 'adaptive' }, + messages: [ + { + role: 'assistant', + content: [{ type: 'redacted_thinking' }, { type: 'text', text: 'Visible' }], + }, + ], + }) + assert.deepEqual(dropped.messages[0].content, [{ type: 'text', text: 'Visible' }]) + const older = stripInvalidThinkingBlocks({ + model: 'claude-opus-5', + thinking: { type: 'adaptive' }, + messages: [ + { + role: 'assistant', + content: [ + { type: 'redacted_thinking', data: 'encrypted' }, + { type: 'text', text: 'Visible' }, + ], + }, + ], + }) + assert.deepEqual(older.messages[0].content, [{ type: 'text', text: 'Visible' }]) + for (const model of ['claude-opus-5-5', 'claude-opus-5']) { + const signed = stripInvalidThinkingBlocks({ + model, + thinking: { type: 'adaptive' }, + messages: [ + { + role: 'assistant', + content: [ + { type: 'redacted_thinking', signature: 'sig_real_1234567890abcdef' }, + { type: 'text', text: 'kept' }, + ], + }, + ], + }) + assert.deepEqual( + signed.messages[0].content.map((block) => block.type), + ['redacted_thinking', 'text'], + model, + ) + } +}) + +test('5.5 preserve path still keeps an invalid signed chain', () => { + const body = stripInvalidThinkingBlocks( + { + model: 'claude-opus-5-5', + thinking: { type: 'adaptive' }, + messages: [ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'old', signature: '' }, + { type: 'thinking', thinking: 'later', signature: 'sig_real_1234567890abcdef' }, + { type: 'redacted_thinking', data: 'encrypted' }, + { type: 'text', text: 'answer' }, + ], + }, + ], + }, + { preserveThinkingBlocks: true }, + ) + assert.deepEqual( + body.messages[0].content.map((block) => block.type), + ['thinking', 'thinking', 'redacted_thinking', 'text'], + ) +}) +test('an untyped thinking block breaks a valid 5.5 chain and is dropped on older models', () => { + const messages = [ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'kept', signature: 'sig_real_1234567890abcdef' }, + { thinking: 'bad' }, + { type: 'text', text: 'answer' }, + ], + }, + ] + const newer = stripInvalidThinkingBlocks({ + model: 'claude-opus-5-5', + thinking: { type: 'adaptive' }, + messages, + }) + assert.deepEqual(newer.messages[0].content, [{ type: 'text', text: 'answer' }]) + const older = stripInvalidThinkingBlocks({ + model: 'claude-opus-5', + thinking: { type: 'adaptive' }, + messages, + }) + assert.deepEqual( + older.messages[0].content.map((block) => block.type || 'untyped'), + ['thinking', 'text'], + ) +}) test('panel preservation creates a missing thinking signature field', () => { const body = prepareAnthropicRequest( diff --git a/test/unit/cli-hop-body.test.mjs b/test/unit/cli-hop-body.test.mjs index b7bc751ce..80a8fb823 100644 --- a/test/unit/cli-hop-body.test.mjs +++ b/test/unit/cli-hop-body.test.mjs @@ -157,7 +157,7 @@ test('prepareCliHopBody keeps existing signatures and fills missing ones', () => ]) }) -test('prepareCliHopBody drops an assistant turn that is only unsigned thinking', () => { +test('prepareCliHopBody keeps a short non-dummy signature on Opus 5.5', () => { const body = prepareCliHopBody({ model: 'claude-opus-5.5', max_tokens: 64000, @@ -174,12 +174,9 @@ test('prepareCliHopBody drops an assistant turn that is only unsigned thinking', assert.equal(body.model, 'claude-opus-5-5') assert.deepEqual( body.messages.map((message) => message.role), - ['user'], + ['user', 'assistant', 'user'], ) - assert.deepEqual(body.messages.at(-1).content, [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'continue', cache_control: { type: 'ephemeral', ttl: '1h' } }, - ]) + assert.deepEqual(body.messages[1].content, [{ type: 'thinking', thinking: 'draft only', signature: 'abc' }]) }) test('prepareCliHopBody repaired does not refill thinking after signature downgrade', () => { diff --git a/test/unit/mid-convo-effort.test.mjs b/test/unit/mid-convo-effort.test.mjs index f496dd288..0f7d508d2 100644 --- a/test/unit/mid-convo-effort.test.mjs +++ b/test/unit/mid-convo-effort.test.mjs @@ -271,7 +271,7 @@ test('withRequestProtocolBetas derives mid-conversation-output-config from messa assert.deepEqual(withRequestProtocolBetas(['caller-beta'], { model: 'claude-sonnet-4-6' }), ['caller-beta']) }) -test('thinking strip keeps empty system effort controls on Opus 5.5', () => { +test('thinking strip keeps empty system effort controls and a valid short signature on Opus 5.5', () => { const body = stripInvalidThinkingBlocks({ model: 'claude-opus-5-5', thinking: { type: 'adaptive' }, @@ -287,7 +287,7 @@ test('thinking strip keeps empty system effort controls on Opus 5.5', () => { }) assert.equal( body.messages.some((message) => message.role === 'assistant'), - false, + true, ) assert.equal( body.messages.some((message) => message.content?.[0]?.text === 'reminder'),