diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 1aa55ac65..a69ef0bd7 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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 => { 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. diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 8757f7101..4feed5ce2 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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..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 { 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 diff --git a/packages/ai/test/issue-826-repro.test.ts b/packages/ai/test/issue-826-repro.test.ts new file mode 100644 index 000000000..b9ad2adbb --- /dev/null +++ b/packages/ai/test/issue-826-repro.test.ts @@ -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"); + }); +});