From d90da82be2092080d5af5309015582acd4f94826 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Jul 2026 19:02:02 +0530 Subject: [PATCH] refactor(ai): normalize provider option parsing (#38695) --- .../ai/src/protocols/anthropic-messages.ts | 56 ++---------------- packages/ai/src/protocols/gemini.ts | 27 +++------ .../src/protocols/utils/anthropic-options.ts | 57 +++++++++++++++++++ .../ai/src/protocols/utils/gemini-options.ts | 27 +++++++++ .../ai/src/providers/anthropic-compatible.ts | 8 ++- .../ai/src/providers/anthropic-options.ts | 26 +++++++++ packages/ai/src/providers/anthropic.ts | 13 ++++- packages/ai/src/providers/gemini-options.ts | 13 +++++ .../src/providers/google-vertex-messages.ts | 8 ++- packages/ai/src/providers/google-vertex.ts | 8 ++- packages/ai/src/providers/google.ts | 7 ++- packages/ai/test/auth-options.types.ts | 28 ++++++++- .../test/provider/anthropic-messages.test.ts | 41 ++++++++++++- packages/ai/test/provider/gemini.test.ts | 21 +++++++ 14 files changed, 256 insertions(+), 84 deletions(-) create mode 100644 packages/ai/src/protocols/utils/anthropic-options.ts create mode 100644 packages/ai/src/protocols/utils/gemini-options.ts create mode 100644 packages/ai/src/providers/anthropic-options.ts create mode 100644 packages/ai/src/providers/gemini-options.ts diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index d8fe2f4468..db0a0e34a1 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -22,6 +22,7 @@ import { import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { classifyProviderFailure } from "../provider-error" import * as Cache from "./utils/cache" +import { AnthropicOptions } from "./utils/anthropic-options" import { Lifecycle } from "./utils/lifecycle" import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" @@ -173,20 +174,6 @@ const AnthropicToolChoice = Schema.Union([ Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), ]) -const AnthropicThinking = Schema.Union([ - Schema.Struct({ - type: Schema.tag("enabled"), - budget_tokens: Schema.Number, - }), - Schema.Struct({ - type: Schema.tag("adaptive"), - display: Schema.optional(Schema.Literals(["summarized", "omitted"])), - }), - Schema.Struct({ - type: Schema.tag("disabled"), - }), -]) - const AnthropicOutputConfig = Schema.Struct({ effort: Schema.optional(Schema.String), }) @@ -203,7 +190,7 @@ const AnthropicBodyFields = { top_p: Schema.optional(Schema.Number), top_k: Schema.optional(Schema.Number), stop_sequences: optionalArray(Schema.String), - thinking: Schema.optional(AnthropicThinking), + thinking: Schema.optional(AnthropicOptions.ThinkingSchema), output_config: Schema.optional(AnthropicOutputConfig), } export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) @@ -537,37 +524,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( return messages }) -const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic - -const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { - const thinking = anthropicOptions(request)?.thinking - if (!ProviderShared.isRecord(thinking)) return undefined - if (thinking.type === "adaptive") { - const display = - thinking.display === "summarized" - ? ("summarized" as const) - : thinking.display === "omitted" - ? ("omitted" as const) - : undefined - return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } - } - if (thinking.type === "disabled") return { type: "disabled" as const } - if (thinking.type !== "enabled") return undefined - const budget = - typeof thinking.budgetTokens === "number" - ? thinking.budgetTokens - : typeof thinking.budget_tokens === "number" - ? thinking.budget_tokens - : undefined - if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens") - return { type: "enabled" as const, budget_tokens: budget } -}) - -const outputConfig = (request: LLMRequest) => { - const effort = anthropicOptions(request)?.effort - return typeof effort === "string" ? { effort } : undefined -} - const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema @@ -587,8 +543,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques ), ) // Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present. - const toolChoice = - tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice) + const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice) const system = request.system.length === 0 ? undefined @@ -603,6 +558,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, ) } + const options = yield* AnthropicOptions.resolve(request) return { model: request.model.id, system, @@ -615,8 +571,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques top_p: generation?.topP, top_k: generation?.topK, stop_sequences: generation?.stop, - thinking: yield* lowerThinking(request), - output_config: outputConfig(request), + thinking: options.thinking, + output_config: options.effort === undefined ? undefined : { effort: options.effort }, } }) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 1c285da235..48eca6f599 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -18,6 +18,7 @@ import { type ToolContent, } from "../schema" import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { GeminiOptions } from "./utils/gemini-options" import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { Lifecycle } from "./utils/lifecycle" import { ToolSchemaProjection } from "./utils/tool-schema" @@ -95,18 +96,13 @@ const GeminiToolConfig = Schema.Struct({ }), }) -const GeminiThinkingConfig = Schema.Struct({ - thinkingBudget: Schema.optional(Schema.Number), - includeThoughts: Schema.optional(Schema.Boolean), -}) - const GeminiGenerationConfig = Schema.Struct({ maxOutputTokens: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number), topP: Schema.optional(Schema.Number), topK: Schema.optional(Schema.Number), stopSequences: optionalArray(Schema.String), - thinkingConfig: Schema.optional(GeminiThinkingConfig), + thinkingConfig: Schema.optional(GeminiOptions.ThinkingConfigSchema), }) const GeminiBodyFields = { @@ -203,7 +199,9 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => { const functionCallId = (providerMetadata: ProviderMetadata | undefined) => { const google = providerMetadata?.google - return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined + return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" + ? google.functionCallId + : undefined } const lowerToolCall = (part: ToolCallPart) => ({ @@ -300,21 +298,10 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR return contents }) -const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini - -const thinkingConfig = (request: LLMRequest) => { - const value = geminiOptions(request)?.thinkingConfig - if (!ProviderShared.isRecord(value)) return undefined - const result = { - thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, - includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, - } - return Object.values(result).some((item) => item !== undefined) ? result : undefined -} - const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const hasTools = request.tools.length > 0 const generation = request.generation + const options = GeminiOptions.resolve(request) const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { maxOutputTokens: generation?.maxTokens, @@ -322,7 +309,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque topP: generation?.topP, topK: generation?.topK, stopSequences: generation?.stop, - thinkingConfig: thinkingConfig(request), + thinkingConfig: options.thinkingConfig, } return { diff --git a/packages/ai/src/protocols/utils/anthropic-options.ts b/packages/ai/src/protocols/utils/anthropic-options.ts new file mode 100644 index 0000000000..63ee00b70c --- /dev/null +++ b/packages/ai/src/protocols/utils/anthropic-options.ts @@ -0,0 +1,57 @@ +import { Effect, Schema } from "effect" +import type { LLMRequest } from "../../schema" +import { ProviderShared } from "../shared" + +export const ThinkingSchema = Schema.Union([ + Schema.Struct({ + type: Schema.tag("enabled"), + budget_tokens: Schema.Number, + }), + Schema.Struct({ + type: Schema.tag("adaptive"), + display: Schema.optional(Schema.Literals(["summarized", "omitted"])), + }), + Schema.Struct({ + type: Schema.tag("disabled"), + }), +]) +export type Thinking = Schema.Schema.Type + +export interface Resolved { + readonly thinking?: Thinking + readonly effort?: string +} + +export const resolve = Effect.fn("AnthropicOptions.resolve")(function* (request: LLMRequest) { + const input = request.providerOptions?.anthropic + return { + thinking: yield* resolveThinking(input?.thinking), + effort: typeof input?.effort === "string" ? input.effort : undefined, + } satisfies Resolved +}) + +const resolveThinking = Effect.fn("AnthropicOptions.resolveThinking")(function* (input: unknown) { + if (!ProviderShared.isRecord(input)) return undefined + if (input.type === "adaptive") { + const display = + input.display === "summarized" + ? ("summarized" as const) + : input.display === "omitted" + ? ("omitted" as const) + : undefined + return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } + } + if (input.type === "disabled") return { type: "disabled" as const } + if (input.type !== "enabled") return undefined + const budget = + typeof input.budgetTokens === "number" + ? input.budgetTokens + : typeof input.budget_tokens === "number" + ? input.budget_tokens + : undefined + if (budget === undefined) + return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens") + return { type: "enabled" as const, budget_tokens: budget } +}) + +export * as AnthropicOptions from "./anthropic-options" diff --git a/packages/ai/src/protocols/utils/gemini-options.ts b/packages/ai/src/protocols/utils/gemini-options.ts new file mode 100644 index 0000000000..f4828a00a4 --- /dev/null +++ b/packages/ai/src/protocols/utils/gemini-options.ts @@ -0,0 +1,27 @@ +import { Schema } from "effect" +import type { LLMRequest } from "../../schema" +import { ProviderShared } from "../shared" + +export const ThinkingConfigSchema = Schema.Struct({ + thinkingBudget: Schema.optional(Schema.Number), + includeThoughts: Schema.optional(Schema.Boolean), +}) +export type ThinkingConfig = Schema.Schema.Type + +export interface Resolved { + readonly thinkingConfig?: ThinkingConfig +} + +export const resolve = (request: LLMRequest): Resolved => { + const value = request.providerOptions?.gemini?.thinkingConfig + if (!ProviderShared.isRecord(value)) return {} + const thinkingConfig = { + thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, + includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, + } + return { + thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined, + } +} + +export * as GeminiOptions from "./gemini-options" diff --git a/packages/ai/src/providers/anthropic-compatible.ts b/packages/ai/src/providers/anthropic-compatible.ts index 1350fae5d5..50867ebb44 100644 --- a/packages/ai/src/providers/anthropic-compatible.ts +++ b/packages/ai/src/providers/anthropic-compatible.ts @@ -3,7 +3,10 @@ import { AnthropicMessages } from "../protocols/anthropic-messages" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" import type { RouteDefaultsInput } from "../route/client" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type ModelID } from "../schema" +import type { AnthropicProviderOptionsInput } from "./anthropic-options" + +export type { AnthropicOptionsInput, AnthropicProviderOptionsInput, AnthropicThinkingInput } from "./anthropic-options" export const id = ProviderID.make("anthropic-compatible") @@ -11,6 +14,7 @@ export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly provider?: string readonly baseURL: string + readonly providerOptions?: AnthropicProviderOptionsInput } export type Settings = ProviderPackage.Settings & @@ -20,7 +24,7 @@ export type Settings = ProviderPackage.Settings & ) & { readonly baseURL: string readonly provider?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: AnthropicProviderOptionsInput } export const routes = [AnthropicMessages.route] diff --git a/packages/ai/src/providers/anthropic-options.ts b/packages/ai/src/providers/anthropic-options.ts new file mode 100644 index 0000000000..61f81cc074 --- /dev/null +++ b/packages/ai/src/providers/anthropic-options.ts @@ -0,0 +1,26 @@ +import type { ProviderOptions } from "../schema" + +export type AnthropicThinkingInput = + | { + readonly type: "adaptive" + readonly display?: "summarized" | "omitted" + } + | { + readonly type: "disabled" + } + | ({ readonly type: "enabled" } & ( + | { readonly budgetTokens: number; readonly budget_tokens?: number } + | { readonly budgetTokens?: number; readonly budget_tokens: number } + )) + +export interface AnthropicOptionsInput { + readonly [key: string]: unknown + readonly thinking?: AnthropicThinkingInput + readonly effort?: string +} + +export type AnthropicProviderOptionsInput = ProviderOptions & { + readonly anthropic?: AnthropicOptionsInput +} + +export * as AnthropicProviderOptions from "./anthropic-options" diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index d8f1e425ba..5eedb63ac3 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,15 +2,22 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" import type { ProviderPackage } from "../provider-package" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type ModelID } from "../schema" import { AnthropicMessages } from "../protocols/anthropic-messages" import { AnthropicCompatible } from "./anthropic-compatible" +import type { AnthropicProviderOptionsInput } from "./anthropic-options" + +export type { AnthropicOptionsInput, AnthropicProviderOptionsInput, AnthropicThinkingInput } from "./anthropic-options" export const id = ProviderID.make("anthropic") export const routes = [AnthropicMessages.route] -export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export type Config = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly providerOptions?: AnthropicProviderOptionsInput + } export type Settings = ProviderPackage.Settings & ( @@ -18,7 +25,7 @@ export type Settings = ProviderPackage.Settings & | { readonly apiKey?: never; readonly authToken?: string } ) & { readonly baseURL?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: AnthropicProviderOptionsInput } const auth = (options: ProviderAuthOption<"optional">) => { diff --git a/packages/ai/src/providers/gemini-options.ts b/packages/ai/src/providers/gemini-options.ts new file mode 100644 index 0000000000..067c38e144 --- /dev/null +++ b/packages/ai/src/providers/gemini-options.ts @@ -0,0 +1,13 @@ +import type { ThinkingConfig } from "../protocols/utils/gemini-options" +import type { ProviderOptions } from "../schema" + +export interface GeminiOptionsInput { + readonly [key: string]: unknown + readonly thinkingConfig?: ThinkingConfig +} + +export type GeminiProviderOptionsInput = ProviderOptions & { + readonly gemini?: GeminiOptionsInput +} + +export * as GeminiProviderOptions from "./gemini-options" diff --git a/packages/ai/src/providers/google-vertex-messages.ts b/packages/ai/src/providers/google-vertex-messages.ts index 7cb6f9cfb2..b52eb94f93 100644 --- a/packages/ai/src/providers/google-vertex-messages.ts +++ b/packages/ai/src/providers/google-vertex-messages.ts @@ -6,9 +6,12 @@ import { Route, type RouteDefaultsInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type ModelID } from "../schema" +import type { AnthropicProviderOptionsInput } from "./anthropic-options" import { GoogleVertexShared } from "./google-vertex-shared" +export type { AnthropicOptionsInput, AnthropicProviderOptionsInput, AnthropicThinkingInput } from "./anthropic-options" + const VERSION = "vertex-2023-10-16" as const // models.dev uses this provider id even though the API contract is Anthropic Messages. @@ -19,6 +22,7 @@ export type Config = RouteDefaultsInput & readonly baseURL?: string readonly location?: string readonly project?: string + readonly providerOptions?: AnthropicProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { @@ -27,7 +31,7 @@ export interface Settings extends ProviderPackage.Settings { readonly baseURL?: string readonly location?: string readonly project?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: AnthropicProviderOptionsInput } const route = Route.make({ diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index a0f44711d4..f7994fb16d 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -4,9 +4,12 @@ import { Auth } from "../route/auth" import { Route, type RouteDefaultsInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type ModelID } from "../schema" +import type { GeminiProviderOptionsInput } from "./gemini-options" import { GoogleVertexShared } from "./google-vertex-shared" +export type { GeminiOptionsInput, GeminiProviderOptionsInput } from "./gemini-options" + export const id = ProviderID.make("google-vertex") export type Config = RouteDefaultsInput & @@ -14,6 +17,7 @@ export type Config = RouteDefaultsInput & readonly baseURL?: string readonly location?: string readonly project?: string + readonly providerOptions?: GeminiProviderOptionsInput } export type Settings = ProviderPackage.Settings & @@ -24,7 +28,7 @@ export type Settings = ProviderPackage.Settings & readonly baseURL?: string readonly location?: string readonly project?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: GeminiProviderOptionsInput } const route = Route.make({ diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 28220bb847..0199d594e6 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -2,11 +2,13 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" import type { ProviderPackage } from "../provider-package" -import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema" +import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema" import { Gemini } from "../protocols/gemini" import { GoogleImages } from "../protocols/google-images" +import type { GeminiProviderOptionsInput } from "./gemini-options" export type { GoogleImageOptions } from "../protocols/google-images" +export type { GeminiOptionsInput, GeminiProviderOptionsInput } from "./gemini-options" export const id = ProviderID.make("google") @@ -15,12 +17,13 @@ export const routes = [Gemini.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string + readonly providerOptions?: GeminiProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { readonly apiKey?: string readonly baseURL?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: GeminiProviderOptionsInput } const auth = (options: ProviderAuthOption<"optional">) => { diff --git a/packages/ai/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index c430106789..4572a0aee5 100644 --- a/packages/ai/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -137,15 +137,26 @@ Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deploym Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") }) Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku") +Anthropic.configure({ + apiKey: "anthropic-key", + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 }, effort: "high" }, + }, +}).model("claude-haiku") // @ts-expect-error Anthropic model selectors only accept model ids. Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {}) // @ts-expect-error Anthropic package settings accept only one auth source. Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" }) +// @ts-expect-error Enabled Anthropic thinking requires a token budget. +Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled" } } } }) +// @ts-expect-error Anthropic thinking budgets must be numbers. +Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: "large" } } } }) AnthropicCompatible.configure({ apiKey: "messages-key", baseURL: "https://messages.example.com/v1", provider: "example", + providerOptions: { anthropic: { thinking: { type: "disabled" } } }, }).model("compatible-model") // @ts-expect-error Anthropic-compatible providers require a base URL. AnthropicCompatible.configure({ apiKey: "messages-key" }) @@ -159,10 +170,19 @@ AnthropicCompatible.model("compatible-model", { }) Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") +Google.configure({ + apiKey: "google-key", + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } }, +}).model("gemini-2.5-flash") // @ts-expect-error Google model selectors only accept model ids. Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {}) +// @ts-expect-error Gemini thinking budgets must be numbers. +Google.configure({ providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } } }) -GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash") +GoogleVertex.configure({ + apiKey: "vertex-key", + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, +}).model("gemini-3.5-flash") GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash") GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash") // @ts-expect-error Vertex Gemini model selectors only accept model ids. @@ -208,7 +228,11 @@ GoogleVertexResponses.configure({ project: "project", }) -GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6") +GoogleVertexMessages.configure({ + accessToken: "vertex-token", + project: "project", + providerOptions: { anthropic: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" } }, +}).model("claude-sonnet-4-6") // @ts-expect-error Vertex Messages package settings do not accept API keys. GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6") diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 021738d75f..564cf49900 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -74,6 +74,42 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("normalizes enabled and disabled thinking settings", () => + Effect.gen(function* () { + const enabled = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } }, + }), + ) + const legacy = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } }, + }), + ) + const disabled = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { anthropic: { thinking: { type: "disabled" } } }, + }), + ) + + expect(enabled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1_024 }) + expect(legacy.body.thinking).toEqual({ type: "enabled", budget_tokens: 2_048 }) + expect(disabled.body.thinking).toEqual({ type: "disabled" }) + }), + ) + + it.effect("rejects enabled thinking without a budget", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { anthropic: { thinking: { type: "enabled" } } }, + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens") + }), + ) + it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -993,7 +1029,10 @@ describe("Anthropic Messages route", () => { content: [ { type: "text", text: "What is in this image?" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, - { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } }, + { + type: "document", + source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" }, + }, ], }, ], diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 66cfb34820..7549f36996 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -36,6 +36,27 @@ describe("Gemini route", () => { }), ) + it.effect("normalizes Gemini thinking options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } }, + }), + ) + const filtered = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } }, + }), + ) + + expect(prepared.body.generationConfig?.thinkingConfig).toEqual({ + thinkingBudget: 0, + includeThoughts: false, + }) + expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false }) + }), + ) + it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(