Merge remote-tracking branch 'origin/farm/0a6674c9/custom-anthropic-replay-default'
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user