fix(catalog): treat github-copilot anthropic proxy as a signing endpoint (#2851)

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.
This commit is contained in:
can1357
2026-06-18 22:44:44 +02:00
parent b9f57fb5d6
commit 2af64d5634
4 changed files with 237 additions and 1 deletions
@@ -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> = {},
): 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);
});
});
+4
View File
@@ -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
+10 -1
View File
@@ -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);
@@ -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">>): 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);
});
});