Browse Source

feat(ai): support Gemini thinking levels (#39796)

Aiden Cline 2 tuần trước cách đây
mục cha
commit
ff2b184af1

+ 2 - 0
packages/ai/AGENTS.md

@@ -12,6 +12,8 @@
 
 Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
 
+- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
+
 ## Tests
 
 - Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.

+ 58 - 4
packages/ai/src/protocols/gemini.ts

@@ -29,9 +29,30 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
 
 export interface OptionsInput {
   readonly [key: string]: unknown
+  readonly cachedContent?: string
+  readonly safetySettings?: ReadonlyArray<{
+    readonly category:
+      | "HARM_CATEGORY_UNSPECIFIED"
+      | "HARM_CATEGORY_HATE_SPEECH"
+      | "HARM_CATEGORY_DANGEROUS_CONTENT"
+      | "HARM_CATEGORY_HARASSMENT"
+      | "HARM_CATEGORY_SEXUALLY_EXPLICIT"
+      | "HARM_CATEGORY_CIVIC_INTEGRITY"
+      | (string & {})
+    readonly threshold:
+      | "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
+      | "BLOCK_LOW_AND_ABOVE"
+      | "BLOCK_MEDIUM_AND_ABOVE"
+      | "BLOCK_ONLY_HIGH"
+      | "BLOCK_NONE"
+      | "OFF"
+      | (string & {})
+  }>
+  readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
   readonly thinkingConfig?: {
     readonly thinkingBudget?: number
     readonly includeThoughts?: boolean
+    readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
   }
 }
 
@@ -111,6 +132,12 @@ const GeminiToolConfig = Schema.Struct({
 const GeminiThinkingConfig = Schema.Struct({
   thinkingBudget: Schema.optional(Schema.Number),
   includeThoughts: Schema.optional(Schema.Boolean),
+  thinkingLevel: Schema.optional(Schema.String),
+})
+
+const GeminiSafetySetting = Schema.Struct({
+  category: Schema.String,
+  threshold: Schema.String,
 })
 
 const GeminiGenerationConfig = Schema.Struct({
@@ -123,7 +150,10 @@ const GeminiGenerationConfig = Schema.Struct({
 })
 
 const GeminiBodyFields = {
+  cachedContent: Schema.optional(Schema.String),
   contents: Schema.Array(GeminiContent),
+  safetySettings: optionalArray(GeminiSafetySetting),
+  serviceTier: Schema.optional(Schema.String),
   systemInstruction: Schema.optional(GeminiSystemInstruction),
   tools: optionalArray(GeminiTool),
   toolConfig: Schema.optional(GeminiToolConfig),
@@ -316,17 +346,38 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
 })
 
 const resolveOptions = (request: LLMRequest) => {
-  const value = request.providerOptions?.gemini?.thinkingConfig
-  if (!ProviderShared.isRecord(value)) return {}
+  const input = request.providerOptions?.gemini
+  const value = input?.thinkingConfig
   const thinkingConfig = {
-    thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
-    includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
+    thinkingBudget:
+      ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
+    includeThoughts:
+      ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
+        ? value.includeThoughts
+        : ProviderShared.isRecord(value)
+          ? true
+          : undefined,
+    thinkingLevel:
+      ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
   }
   return {
+    cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
+    safetySettings: mapSafetySettings(input?.safetySettings),
+    serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
     thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
   }
 }
 
+function mapSafetySettings(value: unknown) {
+  if (!Array.isArray(value)) return undefined
+  const settings = value.flatMap((item) =>
+    ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
+      ? [{ category: item.category, threshold: item.threshold }]
+      : [],
+  )
+  return settings
+}
+
 const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
   const hasTools = request.tools.length > 0
   const generation = request.generation
@@ -342,7 +393,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
   }
 
   return {
+    cachedContent: options.cachedContent,
     contents: yield* lowerMessages(request),
+    safetySettings: options.safetySettings,
+    serviceTier: options.serviceTier,
     systemInstruction:
       request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
     tools: hasTools

+ 30 - 0
packages/ai/test/provider-options/google.types.ts

@@ -9,9 +9,39 @@ LLM.request({
   providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
 })
 
+LLM.request({
+  model,
+  prompt: "Hello",
+  providerOptions: {
+    gemini: {
+      // @ts-expect-error Gemini safety settings require a threshold for every category.
+      safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
+    },
+  },
+})
+
+LLM.request({
+  model,
+  prompt: "Hello",
+  providerOptions: {
+    gemini: {
+      cachedContent: "cachedContents/example",
+      safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
+      serviceTier: "future-tier",
+      thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
+    },
+  },
+})
+
 LLM.request({
   model,
   prompt: "Hello",
   // @ts-expect-error Gemini thinking budgets must be numeric.
   providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
 })
+
+LLM.request({
+  model,
+  prompt: "Hello",
+  providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
+})

+ 29 - 1
packages/ai/test/provider/gemini.test.ts

@@ -41,7 +41,14 @@ describe("Gemini route", () => {
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
         LLMRequest.update(request, {
-          providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
+          providerOptions: {
+            gemini: {
+              cachedContent: "cachedContents/example",
+              safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
+              serviceTier: "priority",
+              thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
+            },
+          },
         }),
       )
       const filtered = yield* compileRequest(
@@ -49,12 +56,33 @@ describe("Gemini route", () => {
           providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
         }),
       )
+      const defaulted = yield* compileRequest(
+        LLMRequest.update(request, {
+          providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
+        }),
+      )
+      const emptySafetySettings = yield* compileRequest(
+        LLMRequest.update(request, {
+          providerOptions: { gemini: { safetySettings: [] } },
+        }),
+      )
 
       expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
         thinkingBudget: 0,
         includeThoughts: false,
+        thinkingLevel: "high",
       })
+      expect(prepared.body.cachedContent).toBe("cachedContents/example")
+      expect(prepared.body.safetySettings).toEqual([
+        { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
+      ])
+      expect(prepared.body.serviceTier).toBe("priority")
       expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
+      expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
+        includeThoughts: true,
+        thinkingLevel: "high",
+      })
+      expect(emptySafetySettings.body.safetySettings).toEqual([])
     }),
   )
 

+ 22 - 1
packages/core/src/aisdk-native.ts

@@ -1,5 +1,7 @@
 export * as AISDKNative from "./aisdk-native"
 
+import { isRecord } from "@opencode-ai/ai/utils/record"
+
 export interface Mapping {
   readonly package: string
   readonly settings: Readonly<Record<string, unknown>>
@@ -14,7 +16,7 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
         settings: {
           ...baseSettings,
           ...mapAPIKey(settings),
-          ...mapProviderOptions("gemini", settings),
+          ...mapGoogleOptions(settings),
         },
       }
     case "@openrouter/ai-sdk-provider":
@@ -48,6 +50,25 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
   return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
 }
 
+function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
+  const input = settings.thinkingConfig
+  const thinkingConfig = {
+    ...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
+    ...(isRecord(input) && typeof input.includeThoughts === "boolean"
+      ? { includeThoughts: input.includeThoughts }
+      : {}),
+    ...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
+  }
+  const options = {
+    ...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
+    ...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
+    ...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
+    ...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
+  }
+  if (Object.keys(options).length === 0) return {}
+  return { providerOptions: { gemini: options } }
+}
+
 function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
   const options = {
     ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),

+ 60 - 0
packages/core/test/aisdk-native.test.ts

@@ -2,6 +2,66 @@ import { describe, expect, test } from "bun:test"
 import { AISDKNative } from "@opencode-ai/core/aisdk-native"
 
 describe("AISDKNative", () => {
+  test("maps every Google thinking setting", () => {
+    expect(
+      AISDKNative.map("@ai-sdk/google", {
+        cachedContent: "cachedContents/example",
+        safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
+        serviceTier: "flex",
+        thinkingConfig: {
+          thinkingBudget: 0,
+          includeThoughts: false,
+          thinkingLevel: "high",
+          unknown: true,
+        },
+      }),
+    ).toEqual({
+      package: "@opencode-ai/ai/providers/google",
+      settings: {
+        providerOptions: {
+          gemini: {
+            cachedContent: "cachedContents/example",
+            safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
+            serviceTier: "flex",
+            thinkingConfig: {
+              thinkingBudget: 0,
+              includeThoughts: false,
+              thinkingLevel: "high",
+            },
+          },
+        },
+      },
+    })
+  })
+
+  test("maps Google thinking settings independently", () => {
+    for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
+      expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
+        settings: { providerOptions: { gemini: { thinkingConfig } } },
+      })
+    }
+  })
+
+  test("maps Google request options without thinking settings", () => {
+    expect(
+      AISDKNative.map("@ai-sdk/google", {
+        cachedContent: "cachedContents/example",
+        safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
+        serviceTier: "future-tier",
+      }),
+    ).toMatchObject({
+      settings: {
+        providerOptions: {
+          gemini: {
+            cachedContent: "cachedContents/example",
+            safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
+            serviceTier: "future-tier",
+          },
+        },
+      },
+    })
+  })
+
   test("maps supported xAI settings", () => {
     expect(
       AISDKNative.map("@ai-sdk/xai", {

+ 21 - 6
packages/core/test/model-resolver.test.ts

@@ -490,16 +490,31 @@ describe("ModelResolver", () => {
     Effect.gen(function* () {
       const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
       const packages = [
-        ["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
-        ["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
-        ["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
+        [
+          "@ai-sdk/google",
+          "@opencode-ai/ai/providers/google",
+          { thinkingConfig: { thinkingLevel: "high" } },
+          { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
+        ],
+        [
+          "@openrouter/ai-sdk-provider",
+          "@opencode-ai/ai/providers/openrouter",
+          { reasoning: { effort: "high" } },
+          { openrouter: { reasoning: { effort: "high" } } },
+        ],
+        [
+          "@ai-sdk/xai",
+          "@opencode-ai/ai/providers/xai",
+          { reasoningEffort: "high" },
+          { xai: { reasoningEffort: "high" } },
+        ],
       ] as const
 
-      yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
+      yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
         ModelResolver.fromCatalogModel(
           model(Provider.aisdk(catalogPackage), {
             modelID: "api-model",
-            settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
+            settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
             headers: { "x-provider": "header" },
             body: { custom: true },
           }),
@@ -516,7 +531,7 @@ describe("ModelResolver", () => {
                     headers: { "x-provider": "header" },
                     body: { custom: true },
                     limits: { context: 100, output: 20 },
-                    providerOptions: { [optionKey]: { reasoningEffort: "high" } },
+                    providerOptions,
                   })
                   return Model.make({ id: modelID, provider: "native-provider", route: native.route })
                 },