fix(ai): add Anthropic compat toggles for Vertex-style proxies

Vertex AI's Anthropic-compatible endpoint rejects strict tool defs
(`tools.<n>.custom.strict: Extra inputs are not permitted`) and the
adaptive thinking tag (`Input tag 'adaptive' ... does not match`).
Add an `AnthropicCompat` shape on `Model.compat` with two opt-in
flags: `disableStrictTools` drops `strict: true` from tool
definitions, `disableAdaptiveThinking` falls back to budget-based
`thinking: { type: "enabled" }`. Default behavior unchanged.

Fixes #826
This commit is contained in:
can1357
2026-04-30 04:28:54 +02:00
parent 895ff6f3a7
commit a190397d88
3 changed files with 157 additions and 4 deletions
+4 -2
View File
@@ -796,7 +796,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ??
"https://api.anthropic.com";
const providerSessionState = getAnthropicProviderSessionState(options?.providerSessionState);
let disableStrictTools = providerSessionState?.strictToolsDisabled ?? false;
let disableStrictTools =
(providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false);
let strictFallbackErrorMessage: string | undefined;
const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
let nextParams = buildParams(model, baseUrl, context, isOAuthToken, options, disableStrictTools);
@@ -1565,7 +1566,8 @@ function buildParams(
const effort =
options.effort ?? (requestedEffort ? mapEffortToAnthropicAdaptiveEffort(model, requestedEffort) : undefined);
if (mode === "anthropic-adaptive") {
const disableAdaptiveThinking = model.compat?.disableAdaptiveThinking ?? false;
if (mode === "anthropic-adaptive" && !disableAdaptiveThinking) {
// Starting with Claude Opus 4.7, adaptive thinking content is omitted from the
// response by default. Opt into summarized reasoning so thinking deltas keep
// streaming with human-readable content for callers that rely on it.
+27 -2
View File
@@ -567,6 +567,27 @@ export interface OpenAICompat {
toolStrictMode?: "all_strict" | "none";
}
/**
* Compatibility settings for anthropic-messages API.
* Use this to disable features that strict-by-default Anthropic accepts but
* that proxy gateways (Vertex AI, AWS Bedrock-style fronts, etc.) reject.
*/
export interface AnthropicCompat {
/**
* Drop the top-level `strict: true` field on tool definitions. Vertex AI's
* Anthropic-compatible endpoint rejects unknown tool fields with
* `tools.<n>.custom.strict: Extra inputs are not permitted`.
*/
disableStrictTools?: boolean;
/**
* Map adaptive thinking (`thinking: { type: "adaptive" }`) to
* `{ type: "enabled", budget_tokens }`. Vertex AI rejects the `adaptive`
* tag with `Input tag 'adaptive' ... does not match any of the expected
* tags: 'disabled', 'enabled'`.
*/
disableAdaptiveThinking?: boolean;
}
/**
* OpenRouter provider routing preferences.
* Controls which upstream providers OpenRouter routes requests to.
@@ -619,8 +640,12 @@ export interface Model<TApi extends Api = any> {
priority?: number;
/** Canonical thinking capability metadata for this model. */
thinking?: ThinkingConfig;
/** Compatibility overrides for openai-completions API. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions" ? OpenAICompat : never;
/** Compatibility overrides per API. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions"
? OpenAICompat
: TApi extends "anthropic-messages"
? AnthropicCompat
: never;
/**
* Which shape to use when exposing the Codex `apply_patch` tool to this model.
* Generated catalog policy sets `"freeform"` for first-party GPT-5 Responses
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "bun:test";
import { streamAnthropic } from "../src/providers/anthropic";
import type { Context, Model, Tool } from "../src/types";
const baseModel: Model<"anthropic-messages"> = {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "anthropic",
baseUrl:
"https://us-east5-aiplatform.googleapis.com/v1/projects/example/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5:streamRawPredict",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
const bashTool: Tool = {
name: "bash",
description: "run a bash command",
parameters: {
type: "object",
properties: { command: { type: "string" } },
required: ["command"],
} as unknown as Tool["parameters"],
};
const baseContext: Context = {
systemPrompt: "Stay concise.",
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
tools: [bashTool],
};
function abortedSignal(): AbortSignal {
const controller = new AbortController();
controller.abort();
return controller.signal;
}
function captureParams(
model: Model<"anthropic-messages">,
): Promise<{ tools?: Array<{ name: string; strict?: unknown }> }> {
const { promise, resolve } = Promise.withResolvers<{ tools?: Array<{ name: string; strict?: unknown }> }>();
void streamAnthropic(model, baseContext, {
apiKey: "sk-ant-api-test",
isOAuth: false,
signal: abortedSignal(),
onPayload: payload => {
resolve(payload as { tools?: Array<{ name: string; strict?: unknown }> });
return undefined;
},
});
return promise;
}
describe("issue #826: Anthropic strict-tools opt-out for Vertex-style proxies", () => {
it("preserves strict:true on allowlisted tools by default (api.anthropic.com baseline)", async () => {
const params = await captureParams(baseModel);
const bash = params.tools?.find(t => t.name === "bash");
expect(bash).toBeDefined();
expect(bash?.strict).toBe(true);
});
it("omits strict on tool defs when compat.disableStrictTools is set", async () => {
const params = await captureParams({
...baseModel,
compat: { disableStrictTools: true },
});
const bash = params.tools?.find(t => t.name === "bash");
expect(bash).toBeDefined();
expect(bash?.strict).toBeUndefined();
});
it("preserves adaptive thinking by default", async () => {
const adaptiveModel: Model<"anthropic-messages"> = {
...baseModel,
id: "claude-opus-4-7",
reasoning: true,
thinking: {
mode: "anthropic-adaptive",
supportsAdaptiveEffort: true,
} as Model<"anthropic-messages">["thinking"],
};
const { promise, resolve } = Promise.withResolvers<{ thinking?: { type?: string } }>();
void streamAnthropic(adaptiveModel, baseContext, {
apiKey: "sk-ant-api-test",
isOAuth: false,
signal: abortedSignal(),
thinkingEnabled: true,
onPayload: payload => {
resolve(payload as { thinking?: { type?: string } });
return undefined;
},
});
const params = await promise;
expect(params.thinking?.type).toBe("adaptive");
});
it("maps adaptive thinking to enabled when compat.disableAdaptiveThinking is set", async () => {
const adaptiveModel: Model<"anthropic-messages"> = {
...baseModel,
id: "claude-opus-4-7",
reasoning: true,
thinking: {
mode: "anthropic-adaptive",
supportsAdaptiveEffort: true,
} as Model<"anthropic-messages">["thinking"],
compat: { disableAdaptiveThinking: true },
};
const { promise, resolve } = Promise.withResolvers<{ thinking?: { type?: string; budget_tokens?: number } }>();
void streamAnthropic(adaptiveModel, baseContext, {
apiKey: "sk-ant-api-test",
isOAuth: false,
signal: abortedSignal(),
thinkingEnabled: true,
onPayload: payload => {
resolve(payload as { thinking?: { type?: string; budget_tokens?: number } });
return undefined;
},
});
const params = await promise;
expect(params.thinking?.type).toBe("enabled");
expect(typeof params.thinking?.budget_tokens).toBe("number");
});
});