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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user