diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b2ac84e13..f02456ebc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed cursor-agent persisted transcripts losing tool-call structure by synthesizing `toolCall` content blocks for exec-channel native tools (`bash`/`read`/`write`/`grep`/`ls`/`delete`/`lsp`), so replay pairs each tool result with its call instead of rendering header-less tool output beneath the last assistant text ([#4348](https://github.com/can1357/oh-my-pi/issues/4348)). - Fixed OpenAI-compatible streaming usage parsing to prefer non-zero nested cached token counts when root `cached_tokens` is zero ([#4337](https://github.com/can1357/oh-my-pi/issues/4337)). - Fixed cursor-agent persisted transcripts losing tool-call structure by synthesizing `toolCall` content blocks for exec-channel native tools (`bash`/`read`/`write`/`grep`/`ls`/`delete`/`lsp`), so replay pairs each tool result with its call instead of rendering header-less tool output beneath the last assistant text ([#4348](https://github.com/can1357/oh-my-pi/issues/4348)). Synthesized blocks carry a new `kCursorExecResolved` symbol marker so the shared agent loop skips executing them a second time. +- Added a runtime signing-endpoint auto-detect on `anthropic-messages`: when an unmarked custom proxy returns `400 Invalid `signature` in `thinking` block`, the transport demotes every unsigned thinking block in the request, retries once, and pins the (baseUrl, modelId) as signing in the provider session state so subsequent turns skip the round-trip. The successful assistant message surfaces `disabledFeatures: ["unsigned-thinking-replay"]` so UIs can prompt the user to persist the change with `compat.replayUnsignedThinking: false` in `models.yml`. Includes an actionable remediation hint on the raw `400` when the auto-retry can't run. ([#4297](https://github.com/can1357/oh-my-pi/issues/4297)) ## [16.3.1] - 2026-07-02 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index e42cfa565..b4949d838 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -329,15 +329,26 @@ const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages"; type AnthropicProviderSessionState = ProviderSessionState & { strictToolsDisabled: boolean; fastModeDisabled: boolean; + /** + * Runtime-learned: this endpoint returned `400 Invalid signature in + * thinking block` for a replayed unsigned thinking block, so it must be + * treated as a signing proxy from now on. All subsequent requests demote + * unsigned thinking to text for this (baseUrl, modelId), same behavior as + * an explicit `compat.replayUnsignedThinking: false`. Cleared on session + * close. + */ + replayUnsignedThinkingDisabled: boolean; }; function createAnthropicProviderSessionState(): AnthropicProviderSessionState { const state: AnthropicProviderSessionState = { strictToolsDisabled: false, fastModeDisabled: false, + replayUnsignedThinkingDisabled: false, close: () => { state.strictToolsDisabled = false; state.fastModeDisabled = false; + state.replayUnsignedThinkingDisabled = false; }, }; return state; @@ -1644,6 +1655,32 @@ function calculateFallbackTurnCost( return true; } +/** + * Detects the Anthropic `400 Invalid `signature` in `thinking` block` failure + * a signing proxy returns when a stripped/unsigned prior thinking block is + * replayed as `signature: ""`. Exported for the compat tests. + */ +const INVALID_THINKING_SIGNATURE_PATTERN = /invalid\s+`?signature`?\s+in\s+`?thinking`?(?:\s+block)?/i; +export function isInvalidThinkingSignatureError(message: string): boolean { + return INVALID_THINKING_SIGNATURE_PATTERN.test(message); +} + +/** + * Prepend a pointed remediation to Anthropic's `Invalid signature in thinking + * block` 400 when the model looks like an unmarked custom signing proxy + * (opaque baseUrl, `spec.reasoning: true`, no explicit + * `compat.replayUnsignedThinking` override). The default is native replay for + * the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy + * case into a one-line fix instead of a silent retry loop (#4297). + */ +export function maybeAddReplayUnsignedThinkingHint(model: Model<"anthropic-messages">, message: string): string { + if (!isInvalidThinkingSignatureError(message)) return message; + if (model.compat.officialEndpoint) return message; + if (model.compatConfig?.replayUnsignedThinking !== undefined) return message; + const hint = `Provider "${model.provider}" looks like an Anthropic-compatible signing proxy: it rejected a replayed unsigned thinking block. Set \`compat.replayUnsignedThinking: false\` under \`providers.${model.provider}\` in your models.yml and retry. See https://github.com/can1357/oh-my-pi/issues/4297.`; + return `${hint}\n\n${message}`; +} + const streamAnthropicOnce = ( model: Model<"anthropic-messages">, context: Context, @@ -1698,6 +1735,7 @@ const streamAnthropicOnce = ( let disableStrictTools = (providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false); let dropFastMode = providerSessionState?.fastModeDisabled ?? false; + let forceDemoteUnsignedThinking = providerSessionState?.replayUnsignedThinkingDisabled ?? false; const mergedCallerHeaders = mergeHeaders(model.headers, options?.headers); const umansGatewayWebSearchHeader = getUmansWebSearchHeader(model, mergedCallerHeaders); @@ -1808,6 +1846,7 @@ const streamAnthropicOnce = ( options, disableStrictTools, umansGatewayWebSearchHeader !== undefined, + forceDemoteUnsignedThinking, ); if (disableStrictTools) { dropAnthropicStrictTools(nextParams); @@ -2377,6 +2416,39 @@ const streamAnthropicOnce = ( firstTokenTime = undefined; continue; } + if ( + !forceDemoteUnsignedThinking && + firstTokenTime === undefined && + !streamedReplayUnsafeContent && + isInvalidThinkingSignatureError( + streamFailure instanceof Error ? streamFailure.message : String(streamFailure), + ) + ) { + logger.warn( + "anthropic: signing proxy detected (Invalid signature in thinking block), demoting unsigned thinking and retrying", + { + provider: model.provider, + model: model.id, + baseUrl, + error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure), + }, + ); + if (providerSessionState) { + providerSessionState.replayUnsignedThinkingDisabled = true; + } + forceDemoteUnsignedThinking = true; + params = await prepareParams(); + providerRetryAttempt = 0; + output.content.length = 0; + output.model = model.id; + output.responseId = undefined; + output.errorMessage = undefined; + output.providerPayload = undefined; + output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests); + output.stopReason = "stop"; + firstTokenTime = undefined; + continue; + } if ( !dropFastMode && model.provider === "anthropic" && @@ -2453,6 +2525,9 @@ const streamAnthropicOnce = ( if (dropFastMode && model.provider === "anthropic" && options?.serviceTier === "priority") { output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"]; } + if (forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking) { + output.disabledFeatures = [...(output.disabledFeatures ?? []), "unsigned-thinking-replay"]; + } stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { @@ -2468,7 +2543,7 @@ const streamAnthropicOnce = ( output.stopReason = result.stopReason; output.errorStatus = result.status; output.errorId = result.id; - output.errorMessage = result.message; + output.errorMessage = maybeAddReplayUnsignedThinkingHint(model, result.message); output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -3025,7 +3100,16 @@ function buildParams( options?: AnthropicOptions, disableStrictTools = false, useUmansGatewayWebSearch = false, + forceDemoteUnsignedThinking = false, ): MessageCreateParamsStreaming { + // A session-scoped auto-demote (learned from a live signing 400) clones the + // resolved compat with `replayUnsignedThinking: false` so every subsequent + // downstream read (convertAnthropicMessages, transformMessages) sees the + // demoted default without mutating the shared `model` reference. + const effectiveModel = + forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking + ? { ...model, compat: { ...model.compat, replayUnsignedThinking: false } } + : model; const { cacheControl } = getCacheControl(model, options?.cacheRetention, isOAuthToken); // Pre-compute system blocks so they occupy the right slot in the serialized body. @@ -3151,7 +3235,7 @@ function buildParams( // metadata → max_tokens → thinking → context_management → output_config → stream. const params: MessageCreateParamsStreaming = { model: options?.requestModelId ?? model.requestModelId ?? model.id, - messages: convertAnthropicMessages(context.messages, model, isOAuthToken, { + messages: convertAnthropicMessages(context.messages, effectiveModel, isOAuthToken, { serverSideFallbackEnabled: !!options?.fallbacks?.length, }), ...(systemBlocks && { system: systemBlocks }), diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index b85c873cf..98695241d 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -341,13 +341,18 @@ export function transformMessages( const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex; // Signature policy is a second axis. Anthropic cryptographically // binds reasoning signatures to its key+session+model, so cross-model - // signatures must be stripped whenever official Anthropic is on - // either end of the replay: - // * official → 3p: the 3p target can't reverify the signature; - // keeping it leaks private continuation metadata for no benefit. - // * 3p → official: official rejects a foreign signature outright. - // * official → official cross-model: the new model rejects the - // previous model's signature. + // signatures must be stripped whenever a signing Anthropic endpoint + // is on either end of the replay: + // * official Anthropic (source): the 3p target can't reverify a + // foreign signature and keeping it leaks continuation metadata + // for no benefit. + // * signing Anthropic (target): official Anthropic, GitHub Copilot, + // ZenMux, Cloudflare AI Gateway `/anthropic`, and Google Vertex + // `publishers/anthropic/…` all forward to signature-enforcing + // Anthropic. Any stale/cross-model signature on the wire triggers + // `400 Invalid signature in thinking block` — same failure class + // whether `officialEndpoint` is true or the endpoint is one of + // the known signing proxies (#4297). // 3p ↔ 3p replays preserve signatures because compatible providers // (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as // opaque continuation hints rather than verified material; stripping @@ -358,8 +363,8 @@ export function transformMessages( // a custom proxy via `models.yaml` will see signatures stripped, the // conservative direction (degraded reasoning, not broken requests). const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic"; - const isOfficialAnthropicTarget = isAnthropicTarget && model.compat.officialEndpoint; - const officialAnthropicInvolved = isOfficialAnthropicSource || isOfficialAnthropicTarget; + const isSigningAnthropicTarget = isAnthropicTarget && model.compat.signingEndpoint; + const signingAnthropicInvolved = isOfficialAnthropicSource || isSigningAnthropicTarget; // Compatible Anthropic-messages reasoning targets that accept // unsigned thinking natively (Z.AI, DeepSeek, the generic // `reasoning && !official` case in the compat builder). Used to keep @@ -421,7 +426,7 @@ export function transformMessages( if ( !isLatestSurvivingAssistant && !isSameModel && - officialAnthropicInvolved && + signingAnthropicInvolved && sanitized.thinkingSignature ) { sanitized = { ...sanitized, thinkingSignature: undefined }; @@ -438,7 +443,7 @@ export function transformMessages( // textual thinking dialect; keep demotion for signatures stripped // by the untrustworthy-turn recovery above and for literal thinking // envelopes that never carried a signature field. - if (isSameModel && isOfficialAnthropicTarget && sanitized.thinkingSignature?.trim() === "") { + if (isSameModel && isSigningAnthropicTarget && sanitized.thinkingSignature?.trim() === "") { return []; } return sanitized; diff --git a/packages/ai/test/anthropic-prior-turn-thinking.test.ts b/packages/ai/test/anthropic-prior-turn-thinking.test.ts index 10023131b..91f99fd24 100644 --- a/packages/ai/test/anthropic-prior-turn-thinking.test.ts +++ b/packages/ai/test/anthropic-prior-turn-thinking.test.ts @@ -25,11 +25,10 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build"; * The signature policy is a second axis: official Anthropic cryptographically * binds signatures to its key+session+model, so cross-model signatures must * be stripped (and matching redacted siblings dropped) whenever either side - * of the replay is official Anthropic. Third-party endpoints (Z.AI, DeepSeek, - * custom anthropic-messages providers) treat signatures as opaque - * continuation hints they pass through unchanged, so 3p ↔ 3p replays - * preserve them as-is to keep the reasoning chain signed for the next - * turn (#2265). + * of the replay is official Anthropic. Unsigned-replay third-party fixtures + * treat signatures as opaque continuation hints they pass through unchanged, + * so 3p ↔ 3p replays preserve them as-is to keep the reasoning chain signed + * for the next turn (#2265). */ function makeAnthropicModel(overrides: Partial> = {}): Model<"anthropic-messages"> { return buildModel({ @@ -422,4 +421,91 @@ describe("Anthropic prior-turn thinking preservation (#2257, #2265)", () => { expect(thinking?.thinking).toBe("openai chain-of-thought"); expect(thinking?.signature).toBe(""); }); + + it("strips stale cross-model signatures when the target is a Cloudflare AI Gateway Anthropic proxy (#4297)", () => { + // cf-anthropic gateway forwards to signature-enforcing Anthropic but + // resolves `officialEndpoint: false`. A prior Claude Sonnet 4.6 turn's + // signature is bound to the source model+session, so replaying it to + // Claude Opus 4.8 on the same gateway would 400 with `Invalid signature + // in thinking block`. Signature stripping must key off the + // `signingEndpoint` classification, not `officialEndpoint`. + const target = makeAnthropicModel({ + provider: "cloudflare-ai-gateway", + id: "cf-anthropic/claude-opus-4-8", + name: "Claude Opus 4.8 via Cloudflare AI Gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/acct/gate/anthropic", + }); + const messages: Message[] = [ + makeUser("Summarize README"), + makeAssistant( + [ + { type: "thinking", thinking: "prior reasoning", thinkingSignature: "sig_prior" }, + { type: "toolCall", id: "toolu_prior", name: "read", arguments: { path: "README.md" } }, + ], + { provider: "cloudflare-ai-gateway", model: "cf-anthropic/claude-sonnet-4-6" }, + ), + toolResult("toolu_prior", "README body"), + makeAssistant( + [ + { type: "thinking", thinking: "opus latest", thinkingSignature: "sig_latest" }, + { type: "text", text: "summary" }, + ], + { provider: "cloudflare-ai-gateway", model: "cf-anthropic/claude-opus-4-8", stopReason: "stop" }, + ), + makeUser("Translate"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const priorBlocks = assistants[0].content as WireBlock[]; + const thinking = priorBlocks.find(b => b.type === "thinking") as WireThinkingBlock | undefined; + // Signature-only replay is unsafe on a signing target with a stale + // (cross-model) source signature — that's the whole 400 failure class. + // The transform strips the signature (so `signingEndpoint` demotes the + // unsigned block to text) and no stale `sig_prior` reaches the wire. + expect(thinking).toBeUndefined(); + const text = priorBlocks.find(b => b.type === "text") as WireTextBlock | undefined; + expect(text?.text).toContain("prior reasoning"); + const wireBlobs = JSON.stringify(priorBlocks); + expect(wireBlobs).not.toContain("sig_prior"); + }); + + it("strips stale cross-model signatures on Google Vertex publishers/anthropic (#4297)", () => { + const target = makeAnthropicModel({ + provider: "google-vertex", + id: "claude-opus-4-8@20260215", + name: "Claude Opus 4.8 via Vertex", + baseUrl: + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/anthropic/models/claude-opus-4-8@20260215:streamRawPredict", + }); + const messages: Message[] = [ + makeUser("Summarize README"), + makeAssistant( + [ + { type: "thinking", thinking: "sonnet reasoning", thinkingSignature: "sig_sonnet" }, + { type: "toolCall", id: "toolu_prior", name: "read", arguments: { path: "README.md" } }, + ], + { provider: "google-vertex", model: "claude-sonnet-4-6@20260101" }, + ), + toolResult("toolu_prior", "README body"), + makeAssistant( + [ + { type: "thinking", thinking: "opus latest", thinkingSignature: "sig_latest" }, + { type: "text", text: "summary" }, + ], + { provider: "google-vertex", model: "claude-opus-4-8@20260215", stopReason: "stop" }, + ), + makeUser("Translate"), + ]; + + const params = convertAnthropicMessages(messages, target, false); + const assistants = params.filter(p => p.role === "assistant"); + const priorBlocks = assistants[0].content as WireBlock[]; + const thinking = priorBlocks.find(b => b.type === "thinking") as WireThinkingBlock | undefined; + expect(thinking).toBeUndefined(); + const text = priorBlocks.find(b => b.type === "text") as WireTextBlock | undefined; + expect(text?.text).toContain("sonnet reasoning"); + const wireBlobs = JSON.stringify(priorBlocks); + expect(wireBlobs).not.toContain("sig_sonnet"); + }); }); diff --git a/packages/ai/test/anthropic-signature-auto-mark.test.ts b/packages/ai/test/anthropic-signature-auto-mark.test.ts new file mode 100644 index 000000000..160988fcb --- /dev/null +++ b/packages/ai/test/anthropic-signature-auto-mark.test.ts @@ -0,0 +1,267 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { AnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { + AssistantMessage, + AssistantMessageEvent, + Context, + Message, + Model, + ProviderSessionState, +} from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +/** + * Regression for #4297 — the anthropic-messages transport auto-heals the very + * first `400 Invalid signature in thinking block` from an unmarked custom + * signing proxy: demote every unsigned thinking block in the request, retry + * once, and pin the (baseUrl, modelId) as signing in the session state so + * subsequent turns skip the demotion round-trip. + */ + +const model: Model<"anthropic-messages"> = buildModel({ + id: "cf-anthropic/claude-opus-4-8", + name: "Claude Opus 4.8 via cloudflared", + api: "anthropic-messages", + provider: "cf-anthropic", + baseUrl: "https://opencode.cloudflare.dev/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, +}); + +const priorTurnContext: Context = { + messages: [ + { role: "user", content: "Summarize README", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Read the file, then summarise.", thinkingSignature: "" }, + { type: "text", text: "The README covers the CLI." }, + ], + api: "anthropic-messages", + provider: "cf-anthropic", + model: "cf-anthropic/claude-opus-4-8", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 0, + } satisfies AssistantMessage, + { role: "user", content: "Translate to French.", timestamp: 0 }, + ] satisfies Message[], +}; + +function createSignatureRejection(): Error { + const error = new Error( + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.1.content.0: Invalid `signature` in `thinking` block"},"request_id":"req_test"}', + ); + Object.assign(error, { status: 400 }); + return error; +} + +interface AnthropicWireBlock { + type: string; + thinking?: string; + text?: string; + signature?: string; +} +interface AnthropicWireMessage { + role: string; + content: AnthropicWireBlock[] | string; +} +interface CapturedRequestPayload { + messages?: AnthropicWireMessage[]; +} +function extractPriorAssistantBlocks(params: unknown): AnthropicWireBlock[] { + if (!params || typeof params !== "object" || !("messages" in params)) return []; + const { messages } = params as CapturedRequestPayload; + if (!Array.isArray(messages)) return []; + for (const msg of messages) { + if (msg.role !== "assistant") continue; + if (typeof msg.content === "string") continue; + return msg.content; + } + return []; +} + +const successEvents = [ + { + type: "message_start", + message: { + id: "msg_ok", + usage: { + input_tokens: 12, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Bonjour." } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 12, + output_tokens: 4, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + { type: "message_stop" }, +] as const; + +function successRequest() { + const response = new Response(null, { status: 200, headers: { "request-id": "req_ok" } }); + return { + async withResponse() { + return { + data: (async function* () { + for (const event of successEvents) { + yield event; + } + })(), + response, + request_id: response.headers.get("request-id"), + }; + }, + }; +} + +function readReplayUnsignedThinkingDisabled(map: Map): boolean | undefined { + for (const [key, value] of map) { + if (!key.startsWith("anthropic-messages")) continue; + if (typeof value !== "object" || value === null) continue; + if (!("replayUnsignedThinkingDisabled" in value)) continue; + const flag = value.replayUnsignedThinkingDisabled; + return typeof flag === "boolean" ? flag : undefined; + } + return undefined; +} + +describe("#4297 anthropic-messages runtime signing auto-mark", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("demotes unsigned thinking, retries, and pins the session on the first signing 400", async () => { + const providerSessionState = new Map(); + const capturedPayloads: unknown[] = []; + let attempt = 0; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { + attempt += 1; + capturedPayloads.push(params); + if (attempt === 1) { + return { + async withResponse() { + throw createSignatureRejection(); + }, + } as never; + } + return successRequest() as never; + }); + + const stream = streamAnthropic(model, priorTurnContext, { + apiKey: "sk-ant-test", + providerSessionState, + }); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) { + events.push(event); + } + const result = await stream.result(); + + expect(attempt).toBe(2); + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + + const firstAttemptBlocks = extractPriorAssistantBlocks(capturedPayloads[0]); + const firstThinking = firstAttemptBlocks.find(block => block.type === "thinking"); + expect(firstThinking?.signature).toBe(""); + expect(firstThinking?.thinking).toBe("Read the file, then summarise."); + + const retryBlocks = extractPriorAssistantBlocks(capturedPayloads[1]); + expect(retryBlocks.find(block => block.type === "thinking")).toBeUndefined(); + const demotedText = retryBlocks.find(block => block.type === "text"); + expect(demotedText?.text).toContain("Read the file, then summarise."); + + expect(readReplayUnsignedThinkingDisabled(providerSessionState)).toBe(true); + expect(result.disabledFeatures).toContain("unsigned-thinking-replay"); + }); + + it("pre-demotes unsigned thinking on subsequent turns once the session is pinned", async () => { + const providerSessionState = new Map(); + const capturedPayloads: unknown[] = []; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { + capturedPayloads.push(params); + return successRequest() as never; + }); + + // Seed the session state as though a prior turn had already auto-marked + // the endpoint. This mirrors the shape produced by the runtime retry so + // subsequent turns never repeat the 400 round-trip. + providerSessionState.set(`anthropic-messages:${model.baseUrl}\u0000${model.id}`, { + close: () => {}, + strictToolsDisabled: false, + fastModeDisabled: false, + replayUnsignedThinkingDisabled: true, + } as ProviderSessionState); + + const stream = streamAnthropic(model, priorTurnContext, { + apiKey: "sk-ant-test", + providerSessionState, + }); + for await (const _ of stream) { + /* drain */ + } + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(capturedPayloads.length).toBe(1); + const blocks = extractPriorAssistantBlocks(capturedPayloads[0]); + expect(blocks.find(block => block.type === "thinking")).toBeUndefined(); + expect(blocks.find(block => block.type === "text")?.text).toContain("Read the file, then summarise."); + expect(result.disabledFeatures).toContain("unsigned-thinking-replay"); + }); + + it("does not auto-mark on unrelated Anthropic invalid_request_error 400s", async () => { + const providerSessionState = new Map(); + let attempt = 0; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { + attempt += 1; + return { + async withResponse() { + const error = new Error( + '400 {"type":"error","error":{"type":"invalid_request_error","message":"Some other validation failure"},"request_id":"req_test"}', + ); + Object.assign(error, { status: 400 }); + throw error; + }, + } as never; + }); + + const stream = streamAnthropic(model, priorTurnContext, { + apiKey: "sk-ant-test", + providerSessionState, + }); + for await (const _ of stream) { + /* drain */ + } + const result = await stream.result(); + + expect(attempt).toBe(1); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("Some other validation failure"); + expect(readReplayUnsignedThinkingDisabled(providerSessionState)).toBe(false); + }); +}); diff --git a/packages/ai/test/anthropic-signature-hint.test.ts b/packages/ai/test/anthropic-signature-hint.test.ts new file mode 100644 index 000000000..19b94ceb6 --- /dev/null +++ b/packages/ai/test/anthropic-signature-hint.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { + isInvalidThinkingSignatureError, + maybeAddReplayUnsignedThinkingHint, +} from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Model, ModelSpec } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +/** + * Regression for #4297 — an unmarked custom `anthropic-messages` signing proxy + * must return an actionable remediation instead of the raw Anthropic 400. + */ +function buildAnthropicMessagesModel( + overrides: Partial> = {}, +): Model<"anthropic-messages"> { + return buildModel({ + api: "anthropic-messages", + provider: "cf-anthropic", + id: "cf-anthropic/claude-opus-4-8", + name: "Claude Opus 4.8 via cloudflared", + baseUrl: "https://opencode.cloudflare.dev/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">); +} + +const SIGNATURE_400 = + '400 {"message":"messages.1.content.0: Invalid `signature` in `thinking` block","type":"invalid_request_error"}'; + +describe("#4297 anthropic-messages replay-unsigned-thinking hint", () => { + it("recognises the Anthropic 400 invalid-thinking-signature body", () => { + expect(isInvalidThinkingSignatureError(SIGNATURE_400)).toBe(true); + expect(isInvalidThinkingSignatureError("Invalid `signature` in `thinking` block")).toBe(true); + expect(isInvalidThinkingSignatureError("Invalid signature in thinking block")).toBe(true); + // #4192 fixture wording — no trailing `block`. The pattern MUST accept + // both because ZenMux / #4192 documents the failure this shorter way. + expect(isInvalidThinkingSignatureError("messages.1.content.0: Invalid `signature` in `thinking`")).toBe(true); + expect(isInvalidThinkingSignatureError("messages.1.content.0: Invalid signature in thinking")).toBe(true); + }); + + it("does not fire on unrelated errors", () => { + expect(isInvalidThinkingSignatureError("400 rate_limit_error")).toBe(false); + expect(isInvalidThinkingSignatureError("Bad Request: missing 'model'")).toBe(false); + }); + + it("prepends a provider-scoped remediation on unmarked custom signing proxies", () => { + const model = buildAnthropicMessagesModel(); + const surfaced = maybeAddReplayUnsignedThinkingHint(model, SIGNATURE_400); + expect(surfaced).not.toBe(SIGNATURE_400); + expect(surfaced).toContain('Provider "cf-anthropic"'); + expect(surfaced).toContain("compat.replayUnsignedThinking: false"); + expect(surfaced).toContain("providers.cf-anthropic"); + expect(surfaced).toContain(SIGNATURE_400); + }); + + it("passes through when the user already set `compat.replayUnsignedThinking`", () => { + const model = buildAnthropicMessagesModel({ compat: { replayUnsignedThinking: false } }); + expect(maybeAddReplayUnsignedThinkingHint(model, SIGNATURE_400)).toBe(SIGNATURE_400); + }); + + it("passes through on official Anthropic (already demoting)", () => { + const model = buildAnthropicMessagesModel({ + provider: "anthropic", + id: "claude-opus-4-8", + baseUrl: "https://api.anthropic.com", + }); + expect(maybeAddReplayUnsignedThinkingHint(model, SIGNATURE_400)).toBe(SIGNATURE_400); + }); + + it("passes through when the error is unrelated (no false positives)", () => { + const model = buildAnthropicMessagesModel(); + expect(maybeAddReplayUnsignedThinkingHint(model, "400 rate_limit_error")).toBe("400 rate_limit_error"); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 0b3a3cf28..f18639af9 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -8,6 +8,9 @@ - Fixed ZenMux model discovery to run without a `ZENMUX_API_KEY`, so newly published ZenMux models (for example `anthropic/claude-fable-5-free`) auto-update into the runtime `models.db` cache instead of waiting on a regenerated `models.json`. - Fixed ZenMux runtime discovery to query the `/api/v1/models` endpoint even when the resolved provider base URL points at the Anthropic-compatible route, so discovery no longer requests a non-existent `/api/anthropic/models` path. +### Fixed + +- Extended the `anthropic-messages` signing-endpoint recognition to Cloudflare AI Gateway (`gateway.ai.cloudflare.com/**/anthropic`), Google Vertex (`aiplatform.googleapis.com/**/publishers/anthropic/…`), AWS Bedrock (`bedrock-runtime..amazonaws.com`), and Azure AI Inference / Foundry (`.(inference|services).ai.azure.com`), and exposed the classification as `ResolvedAnthropicCompat.signingEndpoint` so both the replay-unsigned-thinking default and the cross-model signature-stripping path stay consistent on those hosts without walking back the settled 3p reasoning-replay default. ([#4297](https://github.com/can1357/oh-my-pi/issues/4297)) ## [16.3.1] - 2026-07-02 diff --git a/packages/catalog/src/compat/anthropic.ts b/packages/catalog/src/compat/anthropic.ts index ca0f39df7..717ed5198 100644 --- a/packages/catalog/src/compat/anthropic.ts +++ b/packages/catalog/src/compat/anthropic.ts @@ -36,6 +36,52 @@ function matchesKimiK27CodeFamily(spec: ModelSpec<"anthropic-messages">): boolea return spec.id === "kimi-for-coding" && /k2\.?7 code/i.test(spec.name ?? ""); } +const CLOUDFLARE_ANTHROPIC_GATEWAY_URL_MARKER = /gateway\.ai\.cloudflare\.com\/.+\/anthropic(?:\/|$)/i; +const VERTEX_ANTHROPIC_URL_MARKER = /aiplatform\.googleapis\.com\/.+\/publishers\/anthropic\//i; +const BEDROCK_ANTHROPIC_URL_MARKER = /(?:^|\/\/|\.)bedrock-runtime\.[a-z0-9-]+\.amazonaws\.com/i; +const AZURE_ANTHROPIC_URL_MARKER = /(?:^|\/\/|\.)[a-z0-9-]+\.(?:inference|services)\.ai\.azure\.com/i; + +/** + * Cloudflare AI Gateway's `/anthropic` route forwards to signature-enforcing + * Anthropic (same failure class as GitHub Copilot #2851 / ZenMux #4192). + * Detection is by baseUrl marker rather than provider id: users routinely + * declare `provider: "custom"` (or other free-form ids) in `models.yml` for + * their own Cloudflare gateway account. + */ +function isCloudflareAnthropicGateway(baseUrl?: string): boolean { + return baseUrl !== undefined && CLOUDFLARE_ANTHROPIC_GATEWAY_URL_MARKER.test(baseUrl); +} + +/** + * Google Vertex's `publishers/anthropic/models/…:streamRawPredict` route + * proxies Claude through Google's identity layer and returns full thinking + * signatures, so it is a SIGNING endpoint. + */ +function isVertexAnthropicRoute(baseUrl?: string): boolean { + return baseUrl !== undefined && VERTEX_ANTHROPIC_URL_MARKER.test(baseUrl); +} + +/** + * AWS Bedrock's Anthropic route (`bedrock-runtime..amazonaws.com`) + * forwards Claude requests through Anthropic's signature protocol. Users can + * front Bedrock with a custom `anthropic-messages` provider entry in + * `models.yml`; the URL marker makes those signing by default without + * requiring a provider-id list. + */ +function isBedrockAnthropicRoute(baseUrl?: string): boolean { + return baseUrl !== undefined && BEDROCK_ANTHROPIC_URL_MARKER.test(baseUrl); +} + +/** + * Azure AI Inference / Foundry Anthropic route + * (`.inference.ai.azure.com`, `.services.ai.azure.com`). + * Fronts Claude behind Azure identity and enforces Anthropic signatures on + * replay. + */ +function isAzureAnthropicRoute(baseUrl?: string): boolean { + return baseUrl !== undefined && AZURE_ANTHROPIC_URL_MARKER.test(baseUrl); +} + /** Build the resolved anthropic-messages compat record for a model spec. */ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): ResolvedAnthropicCompat { const baseUrl = spec.baseUrl; @@ -53,8 +99,17 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // (issue #4192). const isZenmux = modelMatchesHost(spec, "zenmux"); const requiresThinkingEnabled = modelMatchesHost(spec, "moonshotNative") && matchesKimiK27CodeFamily(spec); + const signingEndpoint = + official || + isCopilot || + isZenmux || + isCloudflareAnthropicGateway(baseUrl) || + isVertexAnthropicRoute(baseUrl) || + isBedrockAnthropicRoute(baseUrl) || + isAzureAnthropicRoute(baseUrl); const compat: ResolvedAnthropicCompat = { officialEndpoint: official, + signingEndpoint, disableStrictTools: false, disableAdaptiveThinking: false, supportsEagerToolInputStreaming: !isCopilot, @@ -73,26 +128,23 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // into a class that reads `.id`. requiresToolResultId: isZai, requiresThinkingEnabled, - // Official Anthropic enforces signature-based thinking-chain integrity, so - // unsigned thinking blocks must stay text there. Anthropic-compatible - // reasoning endpoints commonly emit unsigned thinking blocks while still - // expecting them back as `type: "thinking"` on continuation; demoting them - // 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. + // Official Anthropic and Anthropic-compatible signing proxies enforce + // signature-based thinking-chain integrity, so unsigned thinking blocks + // must stay text there. Every other `anthropic-messages` reasoning + // endpoint replays unsigned thinking natively so the reasoning chain + // survives continuation and doesn't destabilize the next tool-call + // arguments (#2005, #2257, #2265, #3288, #3433, #3434). Opaque custom + // signing proxies remain the reporter's responsibility to mark with + // `compat.replayUnsignedThinking: false`; the transport surfaces a + // pointed remediation the first time the signing 400 fires (#4297). // - // 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 && - !isZenmux && - (isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official)), + // Known signing Anthropic-messages hosts (Copilot, ZenMux, Cloudflare + // AI Gateway `/anthropic`, Google Vertex `publishers/anthropic`, AWS + // Bedrock `bedrock-runtime..amazonaws.com`, and Azure + // AI Inference / Foundry `.(inference|services).ai.azure.com`) + // are excluded automatically because they can be recognised by provider + // id or baseUrl marker. + replayUnsignedThinking: !signingEndpoint && Boolean(spec.reasoning), escapeBuiltinToolNames: modelMatchesHost(spec, "umans"), }; applyCompatOverrides(compat, spec.compat); diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index e07766923..794024dcb 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -592,6 +592,17 @@ export type ResolvedAnthropicCompat = Required & { * env headers, and cache-TTL shaping without per-request URL parsing. */ officialEndpoint: boolean; + /** + * The configured endpoint enforces Anthropic's signature protocol on + * replayed thinking blocks — either the official API itself or a proxy + * that forwards to it (GitHub Copilot, ZenMux, Cloudflare AI Gateway's + * `/anthropic` route, Google Vertex's `publishers/anthropic/…`). + * Downstream transforms strip stale cross-model thinking signatures on + * these endpoints so the signing proxy doesn't 400 with + * `Invalid signature in thinking block` (#4297). Superset of + * {@link officialEndpoint}. + */ + signingEndpoint: boolean; }; /** diff --git a/packages/catalog/test/issue-4297-repro.test.ts b/packages/catalog/test/issue-4297-repro.test.ts new file mode 100644 index 000000000..96a10502d --- /dev/null +++ b/packages/catalog/test/issue-4297-repro.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test"; +import { buildAnthropicCompat } from "../src/compat/anthropic"; +import type { ModelSpec } from "../src/types"; + +function spec(overrides: Partial> = {}): ModelSpec<"anthropic-messages"> { + return { + api: "anthropic-messages", + id: "anthropic--claude-4.6-opus", + name: "Claude 4.6 Opus via proxy", + provider: "my-proxy", + baseUrl: "http://localhost:6655/anthropic", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8192, + contextWindow: 200000, + reasoning: true, + ...overrides, + } as ModelSpec<"anthropic-messages">; +} + +describe("#4297 anthropic-messages replay-unsigned-thinking classification", () => { + it("keeps native replay on opaque custom reasoning endpoints (no name detection)", () => { + // The reporter's custom Claude proxy is indistinguishable from a + // non-signing third-party reasoning endpoint at config time — the + // default must not walk back #2005's native replay for the 3p + // majority. + expect(buildAnthropicCompat(spec()).replayUnsignedThinking).toBe(true); + }); + + it("keeps native replay on a Cloudflare-internal Claude gateway that is not the AI Gateway route", () => { + // `opencode.cloudflare.dev/anthropic` (issue #4297 comment) is a + // private Cloudflare Workers deployment, not `gateway.ai.cloudflare.com`. + // Opaque custom signing proxy — user marks it with the compat override + // and the transport surfaces the actionable error before then. + expect( + buildAnthropicCompat( + spec({ + id: "cf-anthropic/claude-opus-4-8", + name: "Claude Opus 4.8", + provider: "cf-anthropic", + baseUrl: "https://opencode.cloudflare.dev/anthropic", + }), + ).replayUnsignedThinking, + ).toBe(true); + }); + + it("demotes unsigned thinking on the Cloudflare AI Gateway `/anthropic` route (known signing host)", () => { + const compat = buildAnthropicCompat( + spec({ + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/acct123/gate/anthropic", + }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.signingEndpoint).toBe(true); + expect(compat.officialEndpoint).toBe(false); + }); + + it("demotes unsigned thinking on Google Vertex's publishers/anthropic route (known signing host)", () => { + const compat = buildAnthropicCompat( + spec({ + provider: "google-vertex", + baseUrl: + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/anthropic/models/claude-sonnet-4@20250514:streamRawPredict", + id: "claude-sonnet-4@20250514", + }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.signingEndpoint).toBe(true); + expect(compat.officialEndpoint).toBe(false); + }); + + it("demotes unsigned thinking on AWS Bedrock's anthropic runtime (known signing host)", () => { + const compat = buildAnthropicCompat( + spec({ + provider: "custom-bedrock", + baseUrl: + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-opus-4-8-v1:0/invoke-with-response-stream", + id: "anthropic.claude-opus-4-8-v1:0", + }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.signingEndpoint).toBe(true); + expect(compat.officialEndpoint).toBe(false); + }); + + it("demotes unsigned thinking on Azure AI Inference / Foundry Anthropic routes (known signing host)", () => { + for (const baseUrl of [ + "https://my-project.inference.ai.azure.com/anthropic/v1", + "https://foundry-project.services.ai.azure.com/anthropic/v1", + ]) { + const compat = buildAnthropicCompat(spec({ provider: "custom-azure", baseUrl })); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.signingEndpoint).toBe(true); + expect(compat.officialEndpoint).toBe(false); + } + }); + + it("honors explicit `compat.replayUnsignedThinking: false` on custom signing proxies", () => { + expect(buildAnthropicCompat(spec({ compat: { replayUnsignedThinking: false } })).replayUnsignedThinking).toBe( + false, + ); + }); + + it("preserves native unsigned-thinking replay for the Umans coding-plan anthropic proxy", () => { + const compat = buildAnthropicCompat( + spec({ provider: "umans", baseUrl: "https://api.code.umans.ai/anthropic", id: "glm-5.2" }), + ); + expect(compat.replayUnsignedThinking).toBe(true); + }); + + it("preserves native unsigned-thinking replay for MiniMax's Anthropic-messages proxies", () => { + expect( + buildAnthropicCompat( + spec({ provider: "minimax", baseUrl: "https://api.minimax.io/anthropic", id: "minimax-m2" }), + ).replayUnsignedThinking, + ).toBe(true); + expect( + buildAnthropicCompat( + spec({ provider: "minimax-cn", baseUrl: "https://api.minimaxi.com/anthropic", id: "minimax-m2" }), + ).replayUnsignedThinking, + ).toBe(true); + }); + + it("still demotes unsigned thinking on non-reasoning custom endpoints", () => { + expect(buildAnthropicCompat(spec({ reasoning: false })).replayUnsignedThinking).toBe(false); + }); +});