From 29b5d0eeeca6c8921f581926fe711a04081d0444 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 21:51:14 -0500 Subject: [PATCH 1/6] feat(ai): support Gemini thinking levels --- packages/ai/src/protocols/gemini.ts | 3 ++ .../ai/test/provider-options/google.types.ts | 13 +++++++ packages/ai/test/provider/gemini.test.ts | 5 ++- packages/core/src/aisdk-native.ts | 18 +++++++++- packages/core/test/aisdk-native.test.ts | 34 +++++++++++++++++++ packages/core/test/model-resolver.test.ts | 27 +++++++++++---- 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 03e3c00353c2..7fab476052cc 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -32,6 +32,7 @@ export interface OptionsInput { readonly thinkingConfig?: { readonly thinkingBudget?: number readonly includeThoughts?: boolean + readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" } } @@ -111,6 +112,7 @@ const GeminiToolConfig = Schema.Struct({ const GeminiThinkingConfig = Schema.Struct({ thinkingBudget: Schema.optional(Schema.Number), includeThoughts: Schema.optional(Schema.Boolean), + thinkingLevel: Schema.optional(Schema.String), }) const GeminiGenerationConfig = Schema.Struct({ @@ -321,6 +323,7 @@ const resolveOptions = (request: LLMRequest) => { const thinkingConfig = { thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, + thinkingLevel: typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined, } return { thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined, diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts index 3ccfdfb1a5ce..31fe1817c244 100644 --- a/packages/ai/test/provider-options/google.types.ts +++ b/packages/ai/test/provider-options/google.types.ts @@ -9,9 +9,22 @@ LLM.request({ providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } }, }) +LLM.request({ + model, + prompt: "Hello", + providerOptions: { gemini: { 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", + // @ts-expect-error Gemini thinking levels use Google's supported effort values. + providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } }, +}) diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 07325b970247..fc09e560231a 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -41,7 +41,9 @@ describe("Gemini route", () => { Effect.gen(function* () { const prepared = yield* compileRequest( LLMRequest.update(request, { - providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } }, + providerOptions: { + gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" } }, + }, }), ) const filtered = yield* compileRequest( @@ -53,6 +55,7 @@ describe("Gemini route", () => { expect(prepared.body.generationConfig?.thinkingConfig).toEqual({ thinkingBudget: 0, includeThoughts: false, + thinkingLevel: "high", }) expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false }) }), diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index b748d617d4d2..d76827832ee6 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -14,7 +14,7 @@ export function map(packageName: string | undefined, settings: Readonly>) { return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {} } +function mapGoogleOptions(settings: Readonly>) { + const input = settings.thinkingConfig + if (!isRecord(input)) return {} + const thinkingConfig = { + ...(typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}), + ...(typeof input.includeThoughts === "boolean" ? { includeThoughts: input.includeThoughts } : {}), + ...(typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}), + } + if (Object.keys(thinkingConfig).length === 0) return {} + return { providerOptions: { gemini: { thinkingConfig } } } +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + function mapXAIOptions(settings: Readonly>) { const options = { ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}), diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 13825d4f1077..aa7279206250 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -2,6 +2,40 @@ 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", { + thinkingConfig: { + thinkingBudget: 0, + includeThoughts: false, + thinkingLevel: "high", + unknown: true, + }, + }), + ).toEqual({ + package: "@opencode-ai/ai/providers/google", + settings: { + providerOptions: { + gemini: { + 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 supported xAI settings", () => { expect( AISDKNative.map("@ai-sdk/xai", { diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index b329288da2d7..5cd0699844c6 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/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 }) }, From f590644063abe99e2fbb1fcfa06915e9ea354933 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 22:00:27 -0500 Subject: [PATCH 2/6] refactor(ai): allow future provider enum values --- packages/ai/AGENTS.md | 2 ++ packages/ai/src/protocols/gemini.ts | 2 +- packages/ai/test/provider-options/google.types.ts | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index ad4a625385b0..b1e9fcbfa03a 100644 --- a/packages/ai/AGENTS.md +++ b/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. diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 7fab476052cc..cc2a79ac5c84 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -32,7 +32,7 @@ export interface OptionsInput { readonly thinkingConfig?: { readonly thinkingBudget?: number readonly includeThoughts?: boolean - readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" + readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {}) } } diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts index 31fe1817c244..a886f63fa846 100644 --- a/packages/ai/test/provider-options/google.types.ts +++ b/packages/ai/test/provider-options/google.types.ts @@ -25,6 +25,5 @@ LLM.request({ LLM.request({ model, prompt: "Hello", - // @ts-expect-error Gemini thinking levels use Google's supported effort values. providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } }, }) From 81aa192ebe460d3923880fe2a7d3b3a4d2d0df2b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 22:04:49 -0500 Subject: [PATCH 3/6] feat(ai): include Gemini thoughts by default --- packages/ai/src/protocols/gemini.ts | 2 +- packages/ai/test/provider/gemini.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index cc2a79ac5c84..52795fba1bff 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -322,7 +322,7 @@ const resolveOptions = (request: LLMRequest) => { if (!ProviderShared.isRecord(value)) return {} const thinkingConfig = { thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, - includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, + includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : true, thinkingLevel: typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined, } return { diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index fc09e560231a..d4118f8cea4b 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -51,6 +51,11 @@ describe("Gemini route", () => { providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } }, }), ) + const defaulted = yield* compileRequest( + LLMRequest.update(request, { + providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } }, + }), + ) expect(prepared.body.generationConfig?.thinkingConfig).toEqual({ thinkingBudget: 0, @@ -58,6 +63,10 @@ describe("Gemini route", () => { thinkingLevel: "high", }) expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false }) + expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({ + includeThoughts: true, + thinkingLevel: "high", + }) }), ) From 8fa7144780ef8f0741bd3f76acf605ea96124d1c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 23:03:54 -0500 Subject: [PATCH 4/6] feat(ai): map Gemini request options --- packages/ai/src/protocols/gemini.ts | 61 +++++++++++++++++-- .../ai/test/provider-options/google.types.ts | 9 ++- packages/ai/test/provider/gemini.test.ts | 12 +++- packages/core/src/aisdk-native.ts | 19 ++++-- packages/core/test/aisdk-native.test.ts | 26 ++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 52795fba1bff..30d0569edfca 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -29,6 +29,26 @@ 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 @@ -115,6 +135,11 @@ const GeminiThinkingConfig = Schema.Struct({ thinkingLevel: Schema.optional(Schema.String), }) +const GeminiSafetySetting = Schema.Struct({ + category: Schema.String, + threshold: Schema.String, +}) + const GeminiGenerationConfig = Schema.Struct({ maxOutputTokens: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number), @@ -125,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), @@ -318,18 +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 : true, - thinkingLevel: typeof value.thinkingLevel === "string" ? value.thinkingLevel : 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.length > 0 ? settings : undefined +} + const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const hasTools = request.tools.length > 0 const generation = request.generation @@ -345,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 diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts index a886f63fa846..ca25c6a52b78 100644 --- a/packages/ai/test/provider-options/google.types.ts +++ b/packages/ai/test/provider-options/google.types.ts @@ -12,7 +12,14 @@ LLM.request({ LLM.request({ model, prompt: "Hello", - providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high", includeThoughts: true } } }, + 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({ diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index d4118f8cea4b..9b3ff9259543 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -42,7 +42,12 @@ describe("Gemini route", () => { const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { - gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" } }, + gemini: { + cachedContent: "cachedContents/example", + safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }], + serviceTier: "priority", + thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" }, + }, }, }), ) @@ -62,6 +67,11 @@ describe("Gemini route", () => { 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, diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index d76827832ee6..a497f0633fd1 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -50,14 +50,21 @@ function mapAPIKey(settings: Readonly>) { function mapGoogleOptions(settings: Readonly>) { const input = settings.thinkingConfig - if (!isRecord(input)) return {} const thinkingConfig = { - ...(typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}), - ...(typeof input.includeThoughts === "boolean" ? { includeThoughts: input.includeThoughts } : {}), - ...(typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}), + ...(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 } : {}), } - if (Object.keys(thinkingConfig).length === 0) return {} - return { providerOptions: { gemini: { thinkingConfig } } } + 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 isRecord(value: unknown): value is Readonly> { diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index aa7279206250..bd04920fe9ad 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -5,6 +5,9 @@ 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, @@ -17,6 +20,9 @@ describe("AISDKNative", () => { settings: { providerOptions: { gemini: { + cachedContent: "cachedContents/example", + safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }], + serviceTier: "flex", thinkingConfig: { thinkingBudget: 0, includeThoughts: false, @@ -36,6 +42,26 @@ describe("AISDKNative", () => { } }) + 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", { From 4bbee4ae38e373edf83c0a87f75e2db2a2d08e0e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 23:14:12 -0500 Subject: [PATCH 5/6] refactor(core): reuse shared record guard --- packages/core/src/aisdk-native.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index a497f0633fd1..675e1d8ad9bd 100644 --- a/packages/core/src/aisdk-native.ts +++ b/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> @@ -67,10 +69,6 @@ function mapGoogleOptions(settings: Readonly>) { return { providerOptions: { gemini: options } } } -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - function mapXAIOptions(settings: Readonly>) { const options = { ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}), From cb0d368244d57e2c028fe858af8cdb60924bdd8c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 23:23:51 -0500 Subject: [PATCH 6/6] fix(ai): preserve Gemini safety settings --- packages/ai/src/protocols/gemini.ts | 6 +++--- packages/ai/test/provider-options/google.types.ts | 11 +++++++++++ packages/ai/test/provider/gemini.test.ts | 6 ++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 30d0569edfca..ecaf814b04f8 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -31,7 +31,7 @@ export interface OptionsInput { readonly [key: string]: unknown readonly cachedContent?: string readonly safetySettings?: ReadonlyArray<{ - readonly category?: + readonly category: | "HARM_CATEGORY_UNSPECIFIED" | "HARM_CATEGORY_HATE_SPEECH" | "HARM_CATEGORY_DANGEROUS_CONTENT" @@ -39,7 +39,7 @@ export interface OptionsInput { | "HARM_CATEGORY_SEXUALLY_EXPLICIT" | "HARM_CATEGORY_CIVIC_INTEGRITY" | (string & {}) - readonly threshold?: + readonly threshold: | "HARM_BLOCK_THRESHOLD_UNSPECIFIED" | "BLOCK_LOW_AND_ABOVE" | "BLOCK_MEDIUM_AND_ABOVE" @@ -375,7 +375,7 @@ function mapSafetySettings(value: unknown) { ? [{ category: item.category, threshold: item.threshold }] : [], ) - return settings.length > 0 ? settings : undefined + return settings } const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts index ca25c6a52b78..8ced51055eba 100644 --- a/packages/ai/test/provider-options/google.types.ts +++ b/packages/ai/test/provider-options/google.types.ts @@ -9,6 +9,17 @@ 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", diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 9b3ff9259543..1548df5fe698 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -61,6 +61,11 @@ describe("Gemini route", () => { providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } }, }), ) + const emptySafetySettings = yield* compileRequest( + LLMRequest.update(request, { + providerOptions: { gemini: { safetySettings: [] } }, + }), + ) expect(prepared.body.generationConfig?.thinkingConfig).toEqual({ thinkingBudget: 0, @@ -77,6 +82,7 @@ describe("Gemini route", () => { includeThoughts: true, thinkingLevel: "high", }) + expect(emptySafetySettings.body.safetySettings).toEqual([]) }), )