fix(ai): strip foreign thinking signatures on the latest assistant turn

Switching models into a signing Anthropic endpoint while the latest
surviving assistant turn came from a different anthropic-messages
provider (e.g. kimi-code/k3) replayed that turn's foreign thinking
signature verbatim: the cross-model signature strip in
transformMessages was gated on !isLatestSurvivingAssistant and the
latest-abandoned byte-for-byte exemption was unconditional, so every
request 400'd with `Invalid signature in thinking block` and the
session wedged onto its fallback model until a fallback turn completed
and the poisoned turn stopped being latest.

The latest turn now strips signatures whose issuing provider differs
from the target provider — Anthropic's byte-for-byte rule only covers
the target provider's own latest response — while same-provider
cross-model-id switches keep their byte-for-byte latest turn (pinned
by anthropic-prefill). Foreign redacted_thinking siblings are dropped
alongside instead of riding the wire unverifiable.

Fixes #6379
This commit is contained in:
LunarECL
2026-07-23 20:41:43 +09:00
parent 38efea12ed
commit 5795515af2
3 changed files with 219 additions and 21 deletions
+1
View File
@@ -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.
+41 -21
View File
@@ -602,22 +602,34 @@ export function transformMessages<TApi extends Api>(
? { ...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<TApi extends Api>(
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;
@@ -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("<user_interjection>switch happened here</user_interjection>"),
];
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");
});
});