Merge PR #6718: fix(ai): send interleaved beta through signing proxies (@roboomp)

This commit is contained in:
can1357
2026-07-27 04:58:24 +02:00
4 changed files with 108 additions and 8 deletions
+1
View File
@@ -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
+21 -3
View File
@@ -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;
@@ -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,
+19 -5
View File
@@ -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,