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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.182
1.3.183
7 changes: 4 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 自身行为不受该面板开关改变。

Expand Down
80 changes: 74 additions & 6 deletions src/lib/protocol/anthropic-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -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 = []
Expand All @@ -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) })
Expand Down
168 changes: 166 additions & 2 deletions test/unit/anthropic-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
prepareAnthropicRequest,
stripInvalidThinkingBlocks,
rewriteToolNames,
restoreToolNames,
restoreToolNamesInSSELine,
Expand Down Expand Up @@ -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' },
Expand All @@ -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(
Expand Down
9 changes: 3 additions & 6 deletions test/unit/cli-hop-body.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
Loading
Loading