diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a8584db4..0d703fe36 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed OpenAI Responses replay treating a tool output as paired with a matching call that appeared later in the input, or a tool call as paired with an earlier output. Pair repair now respects wire order before preserving or synthesizing each side. +- Fixed adaptive-thinking Anthropic models omitting the interleaved-thinking beta on signature-enforcing proxies, which caused persisted interleaved assistant turns to fail on replay ([#6717](https://github.com/can1357/oh-my-pi/issues/6717)). ## [17.1.4] - 2026-07-26 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 869847735..dc08672d4 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 { hostMatchesUrl, 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,9 +2851,26 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A } = args; const compat = model.compat; const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools; - const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay; - const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey); const baseUrl = resolveAnthropicBaseUrl(model, apiKey); + // Adaptive models (`supportsDisplay`) get native interleaved thinking on the + // 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. + // Two signing routes still can't take the beta as this `anthropic-beta` HTTP + // header, so they're excluded: Vertex rawPredict accepts betas only in the + // JSON body (`anthropic_beta`) and 400s on the header (#5614), and GitHub + // Copilot rejects Anthropic betas outright — the `github-copilot` provider + // branch below strips them, but a custom provider id or a canonical model + // rerouted to `api.githubcopilot.com` / `copilot-api.*` reaches the generic + // header builder instead, so exclude those effective URLs here too. + const needsInterleavedBeta = + interleavedThinking && + (!model.thinking?.supportsDisplay || + (!isOfficialAnthropicApiUrl(baseUrl) && + isAnthropicSigningProxyUrl(baseUrl) && + !isVertexRawPredictUrl(baseUrl ?? "") && + !hostMatchesUrl(baseUrl, "githubCopilot"))); + const oauthToken = isOAuth ?? isAnthropicOAuthToken(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 cbe94a480..cf9eb9797 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -1804,6 +1804,73 @@ describe("Anthropic request fingerprint alignment", () => { expect(modern.defaultHeaders["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); }); + it("uses the effective route for adaptive interleaved-thinking beta headers", () => { + const adaptiveProxySpec: ModelSpec<"anthropic-messages"> = { + ...ANTHROPIC_MODEL_SPEC, + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + provider: "custom-anthropic", + baseUrl: "https://proxy.example.com/anthropic", + thinking: { + mode: "anthropic-adaptive", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + supportsDisplay: true, + }, + }; + const signingProxyUrl = "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic"; + const signingProxy = buildAnthropicClientOptions({ + 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, + }); + const nonSigningProxy = buildAnthropicClientOptions({ + model: buildModel(adaptiveProxySpec), + apiKey: "sk-proxy-test", + interleavedThinking: true, + }); + // 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: "custom-vertex", + baseUrl: + "https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5/publishers/anthropic/models/claude-opus-4-8:rawPredict", + }), + apiKey: "vertex-adc", + interleavedThinking: true, + }); + // A custom provider on a Copilot host is signing, but the proxy rejects + // Anthropic betas outright and this path bypasses the provider branch. + const copilotUrlProxy = buildAnthropicClientOptions({ + model: buildModel({ + ...adaptiveProxySpec, + provider: "custom-copilot", + baseUrl: "https://api.githubcopilot.com", + }), + apiKey: "ghu_test", + interleavedThinking: true, + }); + + 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(vertexRawPredict.defaultHeaders["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); + expect(copilotUrlProxy.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", () => { const incompatibleModel: Model<"anthropic-messages"> = buildModel({ ...ANTHROPIC_MODEL_SPEC, 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,