diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b90401767..b10bf065f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed sessions wedging onto their fallback model with `400 Invalid \`signature\` in \`thinking\` block` after switching to an Anthropic signing endpoint while the latest assistant turn came from a different Anthropic-compatible provider (e.g. Kimi k3). The cross-model thinking-signature strip skipped the latest surviving assistant turn entirely, replaying the foreign signature verbatim on every attempt; the latest turn now strips signatures whose issuing provider differs from the target (same-provider switches keep their byte-for-byte latest turn), and foreign `redacted_thinking` siblings are dropped alongside instead of riding the wire unverifiable. - Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)). - Fixed OpenAI Responses token-cap truncations suppressing fully streamed function and custom tool calls whose inputs are complete. - Added SuperGrok (`xai-oauth`) usage tracking for weekly credits, product limits, and positive on-demand caps. diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index ecb6f0d05..19ec3e2d6 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -602,22 +602,34 @@ export function transformMessages( ? { ...block, thinkingSignature: undefined } : block; if (isAnthropicReplay) { + // A signature is only replayable where its issuer can verify it. + // Same-provider replays (including cross-model-id switches within + // official Anthropic — pinned by the prefill suite) keep the + // latest turn byte-for-byte per Anthropic's rule for its own most + // recent response. A latest turn minted by a DIFFERENT provider + // is not "Anthropic's own response": its signature can never + // verify on a signing Anthropic target and wedges the session + // with `400 Invalid signature in thinking block` on every + // attempt until the poisoned turn ages out of the replay window + // (observed live: a kimi-code/k3 turn replayed to official + // Anthropic after a session-level model switch mid tool-loop). + const crossProviderSource = assistantMsg.provider !== model.provider; // Latest abandoned turn: Anthropic's byte-for-byte rule forbids - // even stripping a signature on the latest message. - if (isLatestSurvivingAssistant && abandonedToolUse) return block; - // Cross-model prior turns crossing an official Anthropic endpoint - // must strip the source signature so the downstream encoder - // applies its `replayUnsignedThinking` policy (unsigned thinking - // is emitted natively on Anthropic-compatible reasoning endpoints - // and demoted to text on official Anthropic). 3p ↔ 3p replays - // keep the signature so the reasoning chain stays signed on - // continuation (#2265). - if ( - !isLatestSurvivingAssistant && - !isSameModel && - signingAnthropicInvolved && - sanitized.thinkingSignature - ) { + // even stripping a signature on the latest message — but only + // for turns the target's own provider issued. + if (isLatestSurvivingAssistant && abandonedToolUse && !crossProviderSource) return block; + // Strip source signatures crossing an official Anthropic + // endpoint so the downstream encoder applies its + // `replayUnsignedThinking` policy (unsigned thinking is emitted + // natively on Anthropic-compatible reasoning endpoints and + // demoted to text on official Anthropic). Prior turns strip on + // any cross-model transition (#4297); the latest turn strips + // only on a cross-provider transition so same-provider + // continuations stay byte-for-byte. 3p ↔ 3p replays keep the + // signature so the reasoning chain stays signed on continuation + // (#2265). + const staleSignature = isLatestSurvivingAssistant ? crossProviderSource : !isSameModel; + if (staleSignature && signingAnthropicInvolved && sanitized.thinkingSignature) { sanitized = { ...sanitized, thinkingSignature: undefined }; } // Drop blocks with neither a signature anchor nor any text — @@ -687,14 +699,22 @@ export function transformMessages( if (block.type === "redactedThinking") { // Redacted thinking is native-only. Keep it for same-model - // signed replay, the latest byte-for-byte Anthropic turn, or - // compatible targets that will also emit sibling unsigned - // thinking natively. Drop it when the matching visible thinking - // was discarded, or when visible thinking was cross-model - // stripped and will be demoted to text. + // signed replay, for the latest byte-for-byte turn issued by the + // target's own provider, or for compatible targets that will + // also emit sibling unsigned thinking natively. Drop it when the + // matching visible thinking was discarded, or when visible + // thinking was stripped and will be demoted to text — a foreign + // redacted payload can no more verify on a signing target than a + // foreign visible signature can, even on the latest turn. if (isAnthropicReplay) { if (dropsAllSameModelVisibleThinking) return []; - if (isSameModel || isLatestSurvivingAssistant || replaysUnsignedAnthropicThinking) return block; + if ( + isSameModel || + (isLatestSurvivingAssistant && assistantMsg.provider === model.provider) || + replaysUnsignedAnthropicThinking + ) { + return block; + } return []; } if (isSameModel) return block; diff --git a/packages/ai/test/anthropic-prior-turn-thinking.test.ts b/packages/ai/test/anthropic-prior-turn-thinking.test.ts index 84a3b02af..6a1685d11 100644 --- a/packages/ai/test/anthropic-prior-turn-thinking.test.ts +++ b/packages/ai/test/anthropic-prior-turn-thinking.test.ts @@ -638,3 +638,180 @@ describe("Anthropic prior-turn thinking preservation (#2257, #2265)", () => { expect(wireBlobs).not.toContain("sig_sonnet"); }); }); + +describe("Latest-turn foreign thinking signatures (model switch mid tool-loop)", () => { + // Production forensics: a session ran on kimi-code/k3 (an Anthropic- + // compatible endpoint that signs thinking blocks with its own scheme — + // per-turn signatures Anthropic can never verify), the user switched back + // to official Anthropic while the kimi turn was still the LATEST assistant + // message of an in-flight tool loop, and every request 400'd with + // `messages.N.content.M: Invalid \`signature\` in \`thinking\` block`, + // wedging the session onto its fallback model. The signature strip used to + // be gated on `!isLatestSurvivingAssistant`; the latest turn replayed the + // foreign signature verbatim. The latest-turn strip keys on the ISSUER + // (source provider ≠ target provider): same-provider cross-model-id + // switches keep the byte-for-byte latest turn pinned by the prefill suite. + const officialFable = () => + makeAnthropicModel({ + provider: "anthropic", + id: "claude-fable-5", + name: "Claude Fable 5", + baseUrl: "https://api.anthropic.com", + }); + const KIMI_SIG = "kimi-issued-foreign-signature"; + + it("strips a foreign signature from the latest in-flight tool-use turn for official Anthropic", () => { + const reasoning = "The PR body is final and verified. One formatting nit remains."; + const target = officialFable(); + const messages: Message[] = [ + makeUser("Fix the PR body"), + makeAssistant( + [ + { type: "thinking", thinking: reasoning, thinkingSignature: KIMI_SIG }, + { type: "text", text: "한 군데 렌더링 닛: 빈 줄 하나 추가." }, + { type: "toolCall", id: "toolu_send", name: "send", arguments: { text: "patch" } }, + ], + { provider: "kimi-code", model: "k3" }, + ), + toolResult("toolu_send", "sent"), + makeUser("switch happened here"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + expect(assistants).toHaveLength(1); + const blocks = assistants[0].content as WireBlock[]; + // No native thinking block may survive: the foreign signature cannot + // verify, and official Anthropic never replays unsigned thinking. + expect(blocks.find(b => b.type === "thinking")).toBeUndefined(); + expect(JSON.stringify(blocks)).not.toContain(KIMI_SIG); + // The reasoning survives as demoted text ahead of the visible text. + const texts = blocks.filter(b => b.type === "text") as WireTextBlock[]; + expect(texts[0]?.text).toBe(renderDemotedThinking(target.id, reasoning)); + expect(texts.some(t => t.text.includes("렌더링"))).toBe(true); + // The pending tool loop stays intact. + const toolUse = blocks.find(b => b.type === "tool_use") as WireToolUseBlock | undefined; + expect(toolUse?.id).toBe("toolu_send"); + }); + + it("keeps the latest same-model official Anthropic turn byte-for-byte", () => { + const target = officialFable(); + const messages: Message[] = [ + makeUser("Fix the PR body"), + makeAssistant( + [ + { type: "thinking", thinking: "own reasoning", thinkingSignature: "sig_fable" }, + { type: "toolCall", id: "toolu_send", name: "send", arguments: {} }, + ], + { provider: "anthropic", model: "claude-fable-5" }, + ), + toolResult("toolu_send", "sent"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const blocks = assistants[0].content as WireBlock[]; + const thinking = blocks.find(b => b.type === "thinking") as WireThinkingBlock | undefined; + expect(thinking?.thinking).toBe("own reasoning"); + expect(thinking?.signature).toBe("sig_fable"); + }); + + it("strips a foreign signature from the latest ABANDONED tool-use turn for official Anthropic", () => { + // stopReason !== "toolUse" with toolCall blocks = abandoned turn. The + // byte-for-byte exemption only covers Anthropic's own latest response, + // not a foreign one. + const reasoning = "kimi reasoning on an abandoned turn"; + const target = officialFable(); + const messages: Message[] = [ + makeUser("Do the thing"), + makeAssistant( + [ + { type: "thinking", thinking: reasoning, thinkingSignature: KIMI_SIG }, + { type: "toolCall", id: "toolu_left", name: "read", arguments: { path: "a" } }, + ], + { provider: "kimi-code", model: "k3", stopReason: "stop" }, + ), + toolResult("toolu_left", "placeholder"), + makeUser("continue"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const blocks = assistants[0].content as WireBlock[]; + expect(blocks.find(b => b.type === "thinking")).toBeUndefined(); + expect(JSON.stringify(blocks)).not.toContain(KIMI_SIG); + const text = blocks.find(b => b.type === "text") as WireTextBlock | undefined; + expect(text?.text).toBe(renderDemotedThinking(target.id, reasoning)); + }); + + it("keeps the latest ABANDONED same-model turn untouched (byte-for-byte rule)", () => { + const target = officialFable(); + const messages: Message[] = [ + makeUser("Do the thing"), + makeAssistant( + [ + { type: "thinking", thinking: "fable reasoning", thinkingSignature: "sig_fable" }, + { type: "toolCall", id: "toolu_left", name: "read", arguments: { path: "a" } }, + ], + { provider: "anthropic", model: "claude-fable-5", stopReason: "stop" }, + ), + toolResult("toolu_left", "placeholder"), + makeUser("continue"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const blocks = assistants[0].content as WireBlock[]; + const thinking = blocks.find(b => b.type === "thinking") as WireThinkingBlock | undefined; + expect(thinking?.thinking).toBe("fable reasoning"); + expect(thinking?.signature).toBe("sig_fable"); + }); + + it("drops a foreign redacted_thinking sibling on the latest turn for signing targets", () => { + const target = officialFable(); + const messages: Message[] = [ + makeUser("Fix it"), + makeAssistant( + [ + { type: "thinking", thinking: "visible", thinkingSignature: KIMI_SIG }, + { type: "redactedThinking", data: "foreign-blob" }, + { type: "toolCall", id: "toolu_x", name: "read", arguments: {} }, + ], + { provider: "kimi-code", model: "k3" }, + ), + toolResult("toolu_x", "ok"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const blocks = assistants[0].content as WireBlock[]; + expect(blocks.find(b => b.type === "redacted_thinking")).toBeUndefined(); + expect(blocks.find(b => b.type === "thinking")).toBeUndefined(); + }); + + it("strips the latest official Anthropic signature when replaying to an unsigned-replay 3p target", () => { + // Reverse direction: official → 3p. The signature is bound to + // Anthropic's key+session+model; the 3p target replays the thinking + // natively but unsigned. + const target = makeAnthropicModel(); + const messages: Message[] = [ + makeUser("Fix it"), + makeAssistant( + [ + { type: "thinking", thinking: "fable reasoning", thinkingSignature: "sig_fable" }, + { type: "toolCall", id: "toolu_y", name: "read", arguments: {} }, + ], + { provider: "anthropic", model: "claude-fable-5" }, + ), + toolResult("toolu_y", "ok"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const blocks = assistants[0].content as WireBlock[]; + const thinking = blocks.find(b => b.type === "thinking") as WireThinkingBlock | undefined; + expect(thinking?.thinking).toBe("fable reasoning"); + expect(thinking?.signature ?? "").toBe(""); + expect(JSON.stringify(blocks)).not.toContain("sig_fable"); + }); +});