fix(catalog): treated zenmux anthropic proxy as a signing endpoint

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
This commit is contained in:
roboomp
2026-07-02 01:59:35 +00:00
parent 31a8cfc31c
commit 94ccb8a4a5
5 changed files with 262 additions and 9 deletions
@@ -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> = {},
): 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);
});
});
+1
View File
@@ -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
+16 -9
View File
@@ -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);
+2
View File
@@ -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<string, HostClassSpec>;
@@ -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">>): 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);
});
});