Ver Fonte

refactor(ai): normalize provider option parsing (#38695)

Shoubhit Dash há 3 semanas atrás
pai
commit
d90da82be2

+ 6 - 50
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 },
   }
 })
 

+ 7 - 20
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 {

+ 57 - 0
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<typeof ThinkingSchema>
+
+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"

+ 27 - 0
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<typeof ThinkingConfigSchema>
+
+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"

+ 6 - 2
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]

+ 26 - 0
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"

+ 10 - 3
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">) => {

+ 13 - 0
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"

+ 6 - 2
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({

+ 6 - 2
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({

+ 5 - 2
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">) => {

+ 26 - 2
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")

+ 40 - 1
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<AnthropicMessages.AnthropicMessagesBody>(
+        LLM.updateRequest(request, {
+          providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
+        }),
+      )
+      const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+        LLM.updateRequest(request, {
+          providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
+        }),
+      )
+      const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+        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<AnthropicMessages.AnthropicMessagesBody>(
@@ -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=" },
+                      },
                     ],
                   },
                 ],

+ 21 - 0
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<Gemini.GeminiBody>(
+        LLM.updateRequest(request, {
+          providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
+        }),
+      )
+      const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
+        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<Gemini.GeminiBody>(