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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user