From 2af64d5634dbe41ddb38fbbe87baf3e8ac6e5e07 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 18 Jun 2026 22:44:44 +0200 Subject: [PATCH] fix(catalog): treat github-copilot anthropic proxy as a signing endpoint (#2851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Copilot's `anthropic-messages` proxy (api.githubcopilot.com) forwards to signature-enforcing Anthropic and returns full thinking signatures, but the compat builder classified it as a non-signing reasoning endpoint via the generic `reasoning && !official` default (`replayUnsignedThinking: true`). When a checkpoint/branch-return turn is an abandoned tool-use turn (adaptive Opus emits a tool call then ends on `stop`/`end_turn`), `transformMessages` correctly strips its end_turn-bound, unreplayable signature. On a `replayUnsignedThinking` endpoint the encoder then re-emitted that block as `{ type: "thinking", signature: "" }`. An empty signature is rejected by the signature-enforcing backend with `400 Invalid signature`, which corrupts the session and re-trips on every full history re-send (e.g. after toggling MCP servers). Exclude github-copilot from `replayUnsignedThinking` so unsigned/stripped thinking degrades to text exactly like the official Anthropic API — wire-valid and lossless of the tool_use pairing. Z.AI / DeepSeek / other 3p reasoning endpoints (#2005) and cross-model preservation (#2257/#2265) are unaffected. Tests: - packages/catalog/test/anthropic-copilot-signing-compat.test.ts: copilot (incl. enterprise copilot-api.* hosts) -> replayUnsignedThinking false; generic 3p reasoning -> true; official -> false. Fails before / passes after. - packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts: a signing copilot model never emits an empty-signature thinking block for a historical checkpoint turn (demotes to text, keeps tool_use), and still replays a clean signed historical thinking block natively. --- ...ilot-checkpoint-thinking-signature.test.ts | 167 ++++++++++++++++++ packages/catalog/CHANGELOG.md | 4 + packages/catalog/src/compat/anthropic.ts | 11 +- .../anthropic-copilot-signing-compat.test.ts | 56 ++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts create mode 100644 packages/catalog/test/anthropic-copilot-signing-compat.test.ts diff --git a/packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts b/packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts new file mode 100644 index 000000000..417f90227 --- /dev/null +++ b/packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "bun:test"; +import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AssistantMessage, Message, Model, ModelSpec, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +/** + * End-to-end encoder contract for #2851. GitHub Copilot's `anthropic-messages` + * proxy forwards to signature-enforcing Anthropic, so after the fix it is a + * SIGNING endpoint (`replayUnsignedThinking: false` — see the catalog compat + * regression test). This pins the consequence: when a checkpoint/branch-return + * turn is an abandoned tool-use turn (adaptive Opus emits a tool call then ends + * on `stop`), the transform strips its end_turn-bound signature and the encoder + * must DEGRADE the block to text — never replay it as `{ signature: "" }`, which + * 400s the whole request with "Invalid signature" on every full re-send. + * + * The signing classification is asserted directly in + * `packages/catalog/test/anthropic-copilot-signing-compat.test.ts`; the explicit + * `compat` override here models that post-fix classification so the encoder + * behavior is exercised independently of cross-package module resolution. + */ +function copilotSigningModel(): Model<"anthropic-messages"> { + return buildModel({ + api: "anthropic-messages", + provider: "github-copilot", + id: "claude-opus-4.8", + name: "Claude Opus 4.8", + baseUrl: "https://api.githubcopilot.com", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, + // Post-#2851 classification: github-copilot is a signing endpoint. + compat: { replayUnsignedThinking: false }, + } as ModelSpec<"anthropic-messages">); +} + +const emptyUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +type WireBlock = { type: string; signature?: string; thinking?: string; text?: string; id?: string }; +interface WireParam { + role: string; + content: string | WireBlock[]; +} + +function copilotAssistant( + content: AssistantMessage["content"], + overrides: Partial = {}, +): AssistantMessage { + return { + role: "assistant", + content, + api: "anthropic-messages", + provider: "github-copilot", + model: "claude-opus-4.8", + usage: emptyUsage, + stopReason: "stop", + timestamp: 0, + ...overrides, + }; +} + +/** Every thinking block emitted in the whole request, across all params. */ +function allThinkingBlocks(params: WireParam[]): WireBlock[] { + const blocks: WireBlock[] = []; + for (const p of params) { + if (!Array.isArray(p.content)) continue; + for (const b of p.content) { + if (b.type === "thinking") blocks.push(b); + } + } + return blocks; +} + +function assistantBlocksAt(params: WireParam[], index: number): WireBlock[] { + const assistants = params.filter(p => p.role === "assistant"); + const content = assistants[index]?.content; + return Array.isArray(content) ? content : []; +} + +describe("#2851 github-copilot checkpoint/branch-return thinking signature", () => { + it("classifies the explicit signing model as non-replay (sanity)", () => { + expect(copilotSigningModel().compat.replayUnsignedThinking).toBe(false); + }); + + it("never emits an empty-signature thinking block for a historical checkpoint turn", () => { + const model = copilotSigningModel(); + // The store holds a full (~14.6k char) Anthropic signature for the checkpoint + // turn's thinking; the turn ended on `stop` (abandoned tool-use), so its + // signature is end_turn-bound and gets stripped on replay. + const checkpointSig = "real_anthropic_signature_".repeat(600); + const messages: Message[] = [ + { role: "user", content: "investigate the flaky test", timestamp: 1 } satisfies UserMessage, + copilotAssistant( + [ + { type: "thinking", thinking: "Checkpoint first, then explore.", thinkingSignature: checkpointSig }, + { type: "toolCall", id: "toolu_ckpt", name: "checkpoint", arguments: { goal: "find the flake" } }, + ], + { stopReason: "stop", timestamp: 2 }, + ), + { + role: "toolResult", + toolCallId: "toolu_ckpt", + toolName: "checkpoint", + content: [{ type: "text", text: "checkpoint started" }], + isError: false, + timestamp: 3, + } satisfies ToolResultMessage, + // branch-return summary surfaces as a user turn in the rebuilt history + { role: "user", content: "[branch summary] explored, found the race", timestamp: 4 } satisfies UserMessage, + copilotAssistant([{ type: "text", text: "Here's the fix." }], { stopReason: "stop", timestamp: 5 }), + ]; + + const params = convertAnthropicMessages(messages, model, false) as unknown as WireParam[]; + + // (a) Anthropic's all-or-none contract: no thinking block may carry an empty signature. + for (const block of allThinkingBlocks(params)) { + expect(block.signature && block.signature.length > 0).toBeTruthy(); + } + + // The stripped checkpoint thinking is preserved as text (reasoning not silently lost), + // and its tool_use stays paired with the appended tool_result. + const checkpointBlocks = assistantBlocksAt(params, 0); + expect(checkpointBlocks.some(b => b.type === "text" && b.text?.includes("Checkpoint first"))).toBe(true); + expect(checkpointBlocks.some(b => b.type === "thinking")).toBe(false); + expect(checkpointBlocks.some(b => b.type === "tool_use" && b.id === "toolu_ckpt")).toBe(true); + }); + + it("still replays a signed historical thinking block natively (no regression to the common case)", () => { + const model = copilotSigningModel(); + // A clean tool-use turn (stopReason "toolUse") keeps a replayable signature. + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: 1 } satisfies UserMessage, + copilotAssistant( + [ + { type: "thinking", thinking: "I'll read README.", thinkingSignature: "sig_replayable" }, + { type: "toolCall", id: "toolu_read", name: "read", arguments: { path: "README.md" } }, + ], + { stopReason: "toolUse", timestamp: 2 }, + ), + { + role: "toolResult", + toolCallId: "toolu_read", + toolName: "read", + content: [{ type: "text", text: "file body" }], + isError: false, + timestamp: 3, + } satisfies ToolResultMessage, + { role: "user", content: "now summarize", timestamp: 4 } satisfies UserMessage, + copilotAssistant([{ type: "text", text: "Summary." }], { stopReason: "stop", timestamp: 5 }), + ]; + + const params = convertAnthropicMessages(messages, model, false) as unknown as WireParam[]; + for (const block of allThinkingBlocks(params)) { + expect(block.signature && block.signature.length > 0).toBeTruthy(); + } + const firstAssistant = assistantBlocksAt(params, 0); + expect(firstAssistant.some(b => b.type === "thinking" && b.signature === "sig_replayable")).toBe(true); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 7a3c69518..ccd4138b2 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot's `anthropic-messages` proxy being misclassified as a non-signing reasoning endpoint (`replayUnsignedThinking: true`). It forwards to signature-enforcing Anthropic, so replaying a stripped/unsigned historical `thinking` block as `signature: ""` — most visibly an end_turn-bound checkpoint/branch-return turn whose signature the transform must strip — caused a `400 Invalid signature` that corrupted the session and re-tripped on every full history re-send (e.g. after toggling MCP servers). Copilot now degrades such blocks to text like the official API. ([#2851](https://github.com/can1357/oh-my-pi/issues/2851)) + ## [16.0.8] - 2026-06-18 ### Changed diff --git a/packages/catalog/src/compat/anthropic.ts b/packages/catalog/src/compat/anthropic.ts index b7816f876..93390b0e3 100644 --- a/packages/catalog/src/compat/anthropic.ts +++ b/packages/catalog/src/compat/anthropic.ts @@ -66,7 +66,16 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // loses the reasoning chain and can destabilize the next tool-call // arguments (#2005). Known non-signing hosts (Z.AI, DeepSeek) are also // preserved for compatibility. - replayUnsignedThinking: isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official), + // + // GitHub Copilot's `anthropic-messages` proxy is excluded: it forwards to + // signature-enforcing Anthropic and returns full thinking signatures, so it + // is a SIGNING endpoint. Replaying a stripped/unsigned thinking block as + // `signature: ""` there 400s the whole request ("Invalid signature") — most + // visibly when a checkpoint/branch-return turn's end_turn-bound signature is + // stripped on replay (issue #2851). Treating it like official Anthropic + // degrades such blocks to text instead, which the API accepts. + replayUnsignedThinking: + !isCopilot && (isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official)), escapeBuiltinToolNames: modelMatchesHost(spec, "umans"), }; applyCompatOverrides(compat, spec.compat); diff --git a/packages/catalog/test/anthropic-copilot-signing-compat.test.ts b/packages/catalog/test/anthropic-copilot-signing-compat.test.ts new file mode 100644 index 000000000..34d3221e8 --- /dev/null +++ b/packages/catalog/test/anthropic-copilot-signing-compat.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +// Relative import: exercise THIS worktree's compat builder, not the symlinked +// node_modules copy (which resolves to the primary checkout). +import { buildAnthropicCompat } from "../src/compat/anthropic"; +import type { ModelSpec } from "../src/types"; + +/** + * Regression for #2851. GitHub Copilot's `anthropic-messages` proxy forwards to + * signature-enforcing Anthropic and returns full thinking signatures, so it is a + * SIGNING endpoint. It must NOT be classified `replayUnsignedThinking` — otherwise + * a stripped/unsigned historical thinking block (e.g. an end_turn-bound checkpoint + * turn) is replayed as `signature: ""` and 400s the whole request. + */ +function spec(overrides: Partial>): ModelSpec<"anthropic-messages"> { + return { + api: "anthropic-messages", + id: "claude-opus-4.8", + name: "Claude Opus 4.8", + provider: "custom", + baseUrl: "https://llm.example.com/anthropic", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, + ...overrides, + } as ModelSpec<"anthropic-messages">; +} + +describe("#2851 anthropic compat: github-copilot is a signing endpoint", () => { + it("does NOT replay unsigned thinking for the github-copilot anthropic proxy", () => { + const compat = buildAnthropicCompat( + spec({ provider: "github-copilot", baseUrl: "https://api.githubcopilot.com" }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.officialEndpoint).toBe(false); + }); + + it("also excludes github-copilot enterprise (copilot-api.*) hosts", () => { + const compat = buildAnthropicCompat( + spec({ provider: "github-copilot", baseUrl: "https://copilot-api.ghe.example.com" }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + }); + + it("still replays unsigned thinking for generic non-official reasoning endpoints (#2005, no regression)", () => { + const compat = buildAnthropicCompat(spec({ provider: "custom", baseUrl: "https://llm.example.com/anthropic" })); + expect(compat.replayUnsignedThinking).toBe(true); + }); + + it("still degrades unsigned thinking to text for official Anthropic", () => { + const compat = buildAnthropicCompat(spec({ provider: "anthropic", baseUrl: "https://api.anthropic.com" })); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.officialEndpoint).toBe(true); + }); +});