From 94ccb8a4a5865292bf2557ffd697ff2b7fc76bbc Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 01:59:35 +0000 Subject: [PATCH] fix(catalog): treated zenmux anthropic proxy as a signing endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZenMux's `anthropic-messages` route (`zenmux.ai/api/anthropic`) 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`). Same failure class as GitHub Copilot #2851: when a checkpoint/branch-return turn is an abandoned tool-use turn (adaptive Sonnet 5 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 messages.1.content.0: Invalid signature in thinking`. Exclude ZenMux from `replayUnsignedThinking` (via a new `zenmux` host classifier covering the `zenmux` provider id and the `zenmux.ai` url marker) 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-zenmux-signing-compat.test.ts: zenmux (provider id and url marker paths) -> replayUnsignedThinking false; generic 3p reasoning -> true; official -> false. Fails before / passes after. - packages/ai/test/anthropic-zenmux-checkpoint-thinking-signature.test.ts: a derived-compat zenmux sonnet 5 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. Fixes #4192 --- ...nmux-checkpoint-thinking-signature.test.ts | 173 ++++++++++++++++++ packages/catalog/CHANGELOG.md | 1 + packages/catalog/src/compat/anthropic.ts | 25 ++- packages/catalog/src/hosts.ts | 2 + .../anthropic-zenmux-signing-compat.test.ts | 70 +++++++ 5 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 packages/ai/test/anthropic-zenmux-checkpoint-thinking-signature.test.ts create mode 100644 packages/catalog/test/anthropic-zenmux-signing-compat.test.ts diff --git a/packages/ai/test/anthropic-zenmux-checkpoint-thinking-signature.test.ts b/packages/ai/test/anthropic-zenmux-checkpoint-thinking-signature.test.ts new file mode 100644 index 000000000..82612abaa --- /dev/null +++ b/packages/ai/test/anthropic-zenmux-checkpoint-thinking-signature.test.ts @@ -0,0 +1,173 @@ +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 #4192. ZenMux's `anthropic-messages` route + * (`zenmux.ai/api/anthropic`) 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 Sonnet + * 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 + * `messages.1.content.0: Invalid signature in thinking` on every full re-send. + * + * The signing classification is asserted directly in + * `packages/catalog/test/anthropic-zenmux-signing-compat.test.ts`; this file + * exercises the encoder against a spec built from the real derived compat + * (no explicit override) so a future regression in either the classifier or + * the encoder trips one of the two suites. + */ +function zenmuxSonnet5Model(): Model<"anthropic-messages"> { + return buildModel({ + api: "anthropic-messages", + provider: "zenmux", + id: "anthropic/claude-sonnet-5", + name: "Claude Sonnet 5", + baseUrl: "https://zenmux.ai/api/anthropic", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 128_000, + contextWindow: 1_000_000, + reasoning: true, + } 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 zenmuxAssistant( + content: AssistantMessage["content"], + overrides: Partial = {}, +): AssistantMessage { + return { + role: "assistant", + content, + api: "anthropic-messages", + provider: "zenmux", + model: "anthropic/claude-sonnet-5", + 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("#4192 zenmux checkpoint/branch-return thinking signature", () => { + it("classifies the derived signing model as non-replay (sanity)", () => { + expect(zenmuxSonnet5Model().compat.replayUnsignedThinking).toBe(false); + }); + + it("never emits an empty-signature thinking block for a historical checkpoint turn", () => { + const model = zenmuxSonnet5Model(); + // The store holds a full 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, + zenmuxAssistant( + [ + { 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, + { role: "user", content: "[branch summary] explored, found the race", timestamp: 4 } satisfies UserMessage, + zenmuxAssistant([{ 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 = zenmuxSonnet5Model(); + // A clean tool-use turn (stopReason "toolUse") keeps a replayable signature. + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: 1 } satisfies UserMessage, + zenmuxAssistant( + [ + { 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, + zenmuxAssistant([{ 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 9f0ca4296..60b1a7f7e 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed model discovery probes (including Ollama and metadata fetches) failing behind private-CA gateways by ensuring they honor `NODE_EXTRA_CA_CERTS`. - Fixed CoreWeave Serverless Inference project-header detection to ensure blank OpenAI-Project overrides do not block the `COREWEAVE_PROJECT` fallback. - Fixed LiteLLM MiniMax M3 discovery to remove reseller-only display suffixes, and invalidated the model cache to ensure stale suffixes are cleared immediately. +- Fixed ZenMux's `anthropic-messages` proxy being misclassified as a non-signing reasoning endpoint (`replayUnsignedThinking: true`), matching the GitHub Copilot fix (#2851). ZenMux's `zenmux.ai/api/anthropic` route 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 `400 messages.1.content.0: Invalid signature in thinking` on Claude Sonnet 5 and other reasoning models. ([#4192](https://github.com/can1357/oh-my-pi/issues/4192)) ## [16.2.13] - 2026-07-01 diff --git a/packages/catalog/src/compat/anthropic.ts b/packages/catalog/src/compat/anthropic.ts index cdff05ec2..ca0f39df7 100644 --- a/packages/catalog/src/compat/anthropic.ts +++ b/packages/catalog/src/compat/anthropic.ts @@ -42,12 +42,16 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res const official = isOfficialAnthropicApiUrl(baseUrl); // Z.AI's Anthropic-compatible proxy lives at `api.z.ai/api/anthropic`. const isZai = modelMatchesHost(spec, "zai"); - // GitHub Copilot's Anthropic-compatible proxy (api.githubcopilot.com/v1/messages) + // GitHub Copilot's `anthropic-messages` proxy (api.githubcopilot.com/v1/messages) // rejects the per-tool `eager_input_streaming` field with // `tools.0.custom.eager_input_streaming: Extra inputs are not permitted` and // doesn't whitelist the `fine-grained-tool-streaming-2025-05-14` beta either // (issue #2558), so eager tool-input streaming is unavailable on this host. const isCopilot = modelMatchesHost(spec, "githubCopilot"); + // ZenMux's `anthropic-messages` route (zenmux.ai/api/anthropic) forwards to + // signature-enforcing Anthropic — same failure class as GitHub Copilot #2851 + // (issue #4192). + const isZenmux = modelMatchesHost(spec, "zenmux"); const requiresThinkingEnabled = modelMatchesHost(spec, "moonshotNative") && matchesKimiK27CodeFamily(spec); const compat: ResolvedAnthropicCompat = { officialEndpoint: official, @@ -77,15 +81,18 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // arguments (#2005). Known non-signing hosts (Z.AI, DeepSeek) are also // preserved for compatibility. // - // 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. + // GitHub Copilot's `anthropic-messages` proxy and ZenMux's Anthropic route + // are excluded: both forward to signature-enforcing Anthropic and return + // full thinking signatures, so they are SIGNING endpoints. 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 (issues + // #2851, #4192). Treating them like official Anthropic degrades such + // blocks to text instead, which the API accepts. replayUnsignedThinking: - !isCopilot && (isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official)), + !isCopilot && + !isZenmux && + (isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official)), escapeBuiltinToolNames: modelMatchesHost(spec, "umans"), }; applyCompatOverrides(compat, spec.compat); diff --git a/packages/catalog/src/hosts.ts b/packages/catalog/src/hosts.ts index 7afc2a48b..cfa3bd858 100644 --- a/packages/catalog/src/hosts.ts +++ b/packages/catalog/src/hosts.ts @@ -59,6 +59,8 @@ export const KNOWN_HOSTS = { nvidia: { providers: ["nvidia"], urlMarkers: ["integrate.api.nvidia.com"] }, moonshotNative: { providers: ["moonshot", "kimi-code"], urlMarkers: ["api.moonshot.ai", "api.kimi.com"] }, opencode: { providers: ["opencode-go", "opencode-zen"], urlMarkers: ["opencode.ai"] }, + /** ZenMux's Anthropic-compatible proxy (`zenmux.ai/api/anthropic`) forwards to signature-enforcing Anthropic. */ + zenmux: { providers: ["zenmux"], urlMarkers: ["zenmux.ai"] }, chutes: { urlMarkers: ["chutes.ai"] }, } as const satisfies Record; diff --git a/packages/catalog/test/anthropic-zenmux-signing-compat.test.ts b/packages/catalog/test/anthropic-zenmux-signing-compat.test.ts new file mode 100644 index 000000000..ab53bb1e8 --- /dev/null +++ b/packages/catalog/test/anthropic-zenmux-signing-compat.test.ts @@ -0,0 +1,70 @@ +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 #4192. ZenMux's `anthropic-messages` route + * (`zenmux.ai/api/anthropic`) 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, or a cross-model + * replay) is emitted as `signature: ""` and 400s the whole request with + * `messages.1.content.0: Invalid signature in thinking`. Same failure class as + * GitHub Copilot #2851. + */ +function spec(overrides: Partial>): ModelSpec<"anthropic-messages"> { + return { + api: "anthropic-messages", + id: "anthropic/claude-sonnet-5", + name: "Claude Sonnet 5", + provider: "zenmux", + baseUrl: "https://zenmux.ai/api/anthropic", + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 128_000, + contextWindow: 1_000_000, + reasoning: true, + ...overrides, + } as ModelSpec<"anthropic-messages">; +} + +describe("#4192 anthropic compat: zenmux is a signing endpoint", () => { + it("does NOT replay unsigned thinking for the zenmux anthropic proxy (sonnet 5)", () => { + const compat = buildAnthropicCompat(spec({})); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.officialEndpoint).toBe(false); + }); + + it("also excludes the free tier (anthropic/claude-sonnet-5-free)", () => { + const compat = buildAnthropicCompat(spec({ id: "anthropic/claude-sonnet-5-free", name: "Claude Sonnet 5 (Free)" })); + expect(compat.replayUnsignedThinking).toBe(false); + }); + + it("classifies by provider id even if the baseUrl is customized", () => { + // User-configured Zenmux entries may point at a mirror path. Provider id + // is authoritative because the anthropic route always forwards to + // signature-enforcing Anthropic. + const compat = buildAnthropicCompat(spec({ baseUrl: "https://mirror.example.com/zenmux/anthropic" })); + expect(compat.replayUnsignedThinking).toBe(false); + }); + + it("classifies by url marker even under a custom provider id", () => { + const compat = buildAnthropicCompat(spec({ provider: "custom", baseUrl: "https://zenmux.ai/api/anthropic" })); + 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", id: "claude-opus-4.8" }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.officialEndpoint).toBe(true); + }); +});