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