Merge PR #6718: fix(ai): send interleaved beta through signing proxies (@roboomp)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user