diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index ad4a625385..b1e9fcbfa0 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 03e3c00353..ecaf814b04 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/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 diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts index 3ccfdfb1a5..8ced51055e 100644 --- a/packages/ai/test/provider-options/google.types.ts +++ b/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" } } }, +}) diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 07325b9702..1548df5fe6 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/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([]) }), ) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index b748d617d4..675e1d8ad9 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> @@ -14,7 +16,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 + 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>) { 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 13825d4f10..bd04920fe9 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/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", { diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index b329288da2..5cd0699844 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 }) },