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