diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ea5254434..3b109a6a5 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,7 +2,8 @@ import * as nodeCrypto from "node:crypto"; import * as fs from "node:fs"; import { scheduler } from "node:timers/promises"; import * as tls from "node:tls"; -import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic"; +import { isAnthropicSigningProxyUrl, isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic"; +import { isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts"; import { mapEffortToAnthropicAdaptiveEffort } from "@oh-my-pi/pi-catalog/model-thinking"; import { calculateCost, getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils"; @@ -2850,18 +2851,21 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A } = args; const compat = model.compat; const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools; + const baseUrl = resolveAnthropicBaseUrl(model, apiKey); // Adaptive models (`supportsDisplay`) get native interleaved thinking on the - // official API, so the beta is only needed on non-official signing proxies - // that still validate replayed multi-thinking-block turns against it (#6717). + // official API, so only known non-official signing routes need the beta + // (#6717). Classify the effective URL: Foundry and provider overrides can + // reroute a model without rebuilding its materialized compat. // Vertex rawPredict is excluded like every other HTTP-beta path here: it can // only accept betas in the JSON body (`anthropic_beta`), never as this // `anthropic-beta` HTTP header, so advertising it there earns a 400 (#5614). const needsInterleavedBeta = interleavedThinking && (!model.thinking?.supportsDisplay || - (compat.signingEndpoint && !compat.officialEndpoint && model.provider !== "google-vertex")); + (!isOfficialAnthropicApiUrl(baseUrl) && + isAnthropicSigningProxyUrl(baseUrl) && + !isVertexRawPredictUrl(baseUrl ?? ""))); const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey); - const baseUrl = resolveAnthropicBaseUrl(model, apiKey); const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl); const needsFineGrainedToolStreamingBeta = hasTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index d75420403..7028654a6 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -1804,7 +1804,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(modern.defaultHeaders["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); }); - it("adds the interleaved-thinking beta for adaptive models only on signing proxies", () => { + it("uses the effective route for adaptive interleaved-thinking beta headers", () => { const adaptiveProxySpec: ModelSpec<"anthropic-messages"> = { ...ANTHROPIC_MODEL_SPEC, id: "claude-opus-4-8", @@ -1817,12 +1817,21 @@ describe("Anthropic request fingerprint alignment", () => { supportsDisplay: true, }, }; + const signingProxyUrl = "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic"; const signingProxy = buildAnthropicClientOptions({ - model: buildModel({ - ...adaptiveProxySpec, - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic", - }), + model: buildModel({ ...adaptiveProxySpec, baseUrl: signingProxyUrl }), + apiKey: "sk-proxy-test", + interleavedThinking: true, + }); + const canonicalModel = buildModel({ + ...adaptiveProxySpec, + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + }); + const reroutedSigningProxy = buildAnthropicClientOptions({ + // Runtime provider overrides replace baseUrl without rebuilding the + // canonical model's official-endpoint compat. + model: { ...canonicalModel, baseUrl: signingProxyUrl }, apiKey: "sk-proxy-test", interleavedThinking: true, }); @@ -1831,12 +1840,12 @@ describe("Anthropic request fingerprint alignment", () => { apiKey: "sk-proxy-test", interleavedThinking: true, }); - // Vertex rawPredict is a signing endpoint but only accepts betas in the - // JSON body (`anthropic_beta`); the HTTP header would 400 (#5614). - const vertexProxy = buildAnthropicClientOptions({ + // Vertex rawPredict is signing regardless of provider id, but only + // accepts betas in the JSON body (`anthropic_beta`) (#5614). + const vertexRawPredict = buildAnthropicClientOptions({ model: buildModel({ ...adaptiveProxySpec, - provider: "google-vertex", + provider: "custom-vertex", baseUrl: "https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5/publishers/anthropic/models/claude-opus-4-8:rawPredict", }), @@ -1845,8 +1854,9 @@ describe("Anthropic request fingerprint alignment", () => { }); expect(signingProxy.defaultHeaders["anthropic-beta"]).toContain("interleaved-thinking-2025-05-14"); + expect(reroutedSigningProxy.defaultHeaders["anthropic-beta"]).toContain("interleaved-thinking-2025-05-14"); expect(nonSigningProxy.defaultHeaders["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); - expect(vertexProxy.defaultHeaders["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); + expect(vertexRawPredict.defaultHeaders["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); }); it("adds legacy fine-grained tool-streaming beta only for tool requests on incompatible models", () => { diff --git a/packages/catalog/src/compat/anthropic.ts b/packages/catalog/src/compat/anthropic.ts index af3a5ac6c..f5e1be87f 100644 --- a/packages/catalog/src/compat/anthropic.ts +++ b/packages/catalog/src/compat/anthropic.ts @@ -4,7 +4,7 @@ * defaults come from provider ids, strict host checks, and model-id * classification, with explicit spec overrides assigned on top. */ -import { modelMatchesHost } from "../hosts"; +import { hostMatchesUrl, modelMatchesHost } from "../hosts"; import { hasOpus47ApiRestrictions, isAnthropicFableOrMythosModel, @@ -96,6 +96,23 @@ function isAzureAnthropicRoute(baseUrl?: string): boolean { return baseUrl !== undefined && AZURE_ANTHROPIC_URL_MARKER.test(baseUrl); } +/** + * Known non-official URLs that enforce Anthropic thinking signatures on replay. + * + * Runtime routing calls this with the effective URL because a model's resolved + * compat can be stale after Foundry or a provider base-URL override reroutes it. + */ +export function isAnthropicSigningProxyUrl(baseUrl?: string): boolean { + return ( + hostMatchesUrl(baseUrl, "githubCopilot") || + hostMatchesUrl(baseUrl, "zenmux") || + isCloudflareAnthropicGateway(baseUrl) || + isVertexAnthropicRoute(baseUrl) || + isBedrockAnthropicRoute(baseUrl) || + isAzureAnthropicRoute(baseUrl) + ); +} + /** Build the resolved anthropic-messages compat record for a model spec. */ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): ResolvedAnthropicCompat { const baseUrl = spec.baseUrl; @@ -113,11 +130,8 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // (issue #4192). const isZenmux = modelMatchesHost(spec, "zenmux"); const requiresThinkingEnabled = modelMatchesHost(spec, "moonshotNative") && matchesKimiMandatoryThinkingModel(spec); - const isVertex = isVertexAnthropicRoute(baseUrl); - const isBedrock = isBedrockAnthropicRoute(baseUrl); const isAzure = isAzureAnthropicRoute(baseUrl); - const signingEndpoint = - official || isCopilot || isZenmux || isCloudflareAnthropicGateway(baseUrl) || isVertex || isBedrock || isAzure; + const signingEndpoint = official || isCopilot || isZenmux || isAnthropicSigningProxyUrl(baseUrl); const compat: ResolvedAnthropicCompat = { officialEndpoint: official, signingEndpoint,