Merge remote-tracking branch 'origin/farm/0a6674c9/custom-anthropic-replay-default'

This commit is contained in:
can1357
2026-07-02 23:34:36 +02:00
10 changed files with 752 additions and 37 deletions
+1
View File
@@ -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
+86 -2
View File
@@ -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 }),
+16 -11
View File
@@ -341,13 +341,18 @@ export function transformMessages<TApi extends Api>(
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<TApi extends Api>(
// 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<TApi extends Api>(
if (
!isLatestSurvivingAssistant &&
!isSameModel &&
officialAnthropicInvolved &&
signingAnthropicInvolved &&
sanitized.thinkingSignature
) {
sanitized = { ...sanitized, thinkingSignature: undefined };
@@ -438,7 +443,7 @@ export function transformMessages<TApi extends Api>(
// 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;
@@ -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<ModelSpec<"anthropic-messages">> = {}): 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");
});
});
@@ -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<string, ProviderSessionState>): 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<string, ProviderSessionState>();
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<string, ProviderSessionState>();
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<string, ProviderSessionState>();
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);
});
});
@@ -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<ModelSpec<"anthropic-messages">> = {},
): 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");
});
});
+3
View File
@@ -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.<region>.amazonaws.com`), and Azure AI Inference / Foundry (`<res>.(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
+71 -19
View File
@@ -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.<region>.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
* (`<resource>.inference.ai.azure.com`, `<resource>.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.<region>.amazonaws.com`, and Azure
// AI Inference / Foundry `<res>.(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);
+11
View File
@@ -592,6 +592,17 @@ export type ResolvedAnthropicCompat = Required<AnthropicCompat> & {
* 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;
};
/**
@@ -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">> = {}): 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);
});
});