diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 10da3e46910..60ec3c49e68 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1034,7 +1034,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques const format = outputConfig?.format ?? undefined const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined) const generation = request.generation - const toolSchemaCompatibility = request.model.compatibility?.toolSchema // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. @@ -1044,11 +1043,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => - lowerTool( - breakpoints, - tool, - ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), - ), + lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)), ) // 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) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 68dd6ff83f2..5b30c7fb342 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -11,7 +11,7 @@ import { type FinishReasonDetails, type JsonSchema, type LLMRequest, - type LanguageModelToolSchemaCompatibility, + type LanguageModel, type ProviderMetadata, type ReasoningPart, type ToolCallPart, @@ -230,13 +230,13 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo }) const lowerTools = ( - compatibility: LanguageModelToolSchemaCompatibility | undefined, + model: LanguageModel, breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray, ): BedrockTool[] => { const result: BedrockTool[] = [] for (const tool of tools) { - result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))) + result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model))) const cachePoint = BedrockCache.block(breakpoints, tool.cache) if (cachePoint) result.push(cachePoint) } @@ -440,7 +440,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: const toolConfig = (() => { if (flattened.tools.length === 0) return undefined return { - tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, flattened.tools), + tools: lowerTools(request.model, breakpoints, flattened.tools), // Converse has no native "none". Keep definitions stable for prompt // caching and omit only the unsupported choice. toolChoice, diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 4ed948872df..8ece9cdb292 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -10,8 +10,8 @@ import { LLMEvent, Usage, type FinishReason, - type JsonSchema, type LLMRequest, + type LanguageModel, type MediaPart, type ProviderMetadata, type ProviderOptions, @@ -23,7 +23,6 @@ import { classifyProviderFailure } from "../provider-error.js" import { Media } from "../media.js" import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { GeminiGenerateContent } from "./utils/gemini-generate-content.js" -import { GeminiJsonSchema } from "./utils/gemini-json-schema.js" import { Lifecycle } from "./utils/lifecycle.js" import { ToolSchemaProjection } from "./utils/tool-schema.js" @@ -269,12 +268,12 @@ interface ParserState { // ============================================================================= // Request Lowering // ============================================================================= -// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. -// `utils/gemini-json-schema` rewrites only the few shapes Gemini still rejects. -const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({ +// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. Gemini's schema +// rules are this API's default, including for tuned endpoints whose IDs do not name Gemini. +const lowerTool = (tool: ToolDefinition, model: LanguageModel) => ({ name: tool.name, description: tool.description, - parametersJsonSchema: GeminiJsonSchema.normalize(inputSchema), + parametersJsonSchema: ToolSchemaProjection.modelCompatibility(tool.inputSchema, model, "gemini"), }) const lowerToolConfig = (toolChoice: NonNullable) => @@ -444,7 +443,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque const hasTools = flattened.tools.length > 0 const generation = request.generation const options = yield* decodeOptions(request.providerOptions ?? {}) - const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { maxOutputTokens: generation?.maxTokens, temperature: generation?.temperature, @@ -470,9 +468,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque tools: hasTools ? [ { - functionDeclarations: flattened.tools.map((tool) => - lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), - ), + functionDeclarations: flattened.tools.map((tool) => lowerTool(tool, request.model)), }, ] : undefined, diff --git a/packages/ai/src/protocols/meta-responses.ts b/packages/ai/src/protocols/meta-responses.ts index d915b369ae9..5e392550d49 100644 --- a/packages/ai/src/protocols/meta-responses.ts +++ b/packages/ai/src/protocols/meta-responses.ts @@ -107,7 +107,7 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L return yield* OpenResponses.lowerTool( NAME, tool, - ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model.compatibility?.toolSchema), + ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model), ) return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta) }), diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index a2bdcb3bdad..f42f03022b8 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -818,7 +818,6 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd adapter: ProviderAdapter, ) { const projected = ProviderShared.flattenToolRequest(request) - const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { ...(yield* lowerConversation(projected.request, adapter)), ...lowerGeneration(request), @@ -828,11 +827,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd : yield* Effect.forEach(projected.tools, (tool) => tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) - : lowerTool( - adapter.name, - tool, - ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), - ), + : lowerTool(adapter.name, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)), ), tool_choice: allowedToolChoice(request) ?? diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index ca115c78c3e..3d4b06a3c97 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -803,7 +803,6 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( `OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`, ) const generation = request.generation - const toolSchemaCompatibility = request.model.compatibility?.toolSchema const flattened = ProviderShared.flattenToolRequest(request) const provider = String(request.model.provider) const baseURL = request.model.route.endpoint.baseURL @@ -829,7 +828,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( : flattened.tools.map((tool) => lowerTool( tool, - ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), + ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model), options, supportsStrictMode, ), diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 82eacbd5b29..3a1b84108b3 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -5,7 +5,14 @@ import { Auth } from "../route/auth.js" import { Endpoint } from "../route/endpoint.js" import { Protocol } from "../route/protocol.js" import { HttpTransport } from "../route/transport/index.js" -import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js" +import { + LLMRequest, + mergeJsonRecords, + type JsonSchema, + type LanguageModel, + type ToolDefinition, + type ToolEntry, +} from "../schema/index.js" import { resolveEffortUpdates } from "../effort-updates.js" import { OpenResponses } from "./open-responses.js" import { OpenResponsesOptions } from "./utils/open-responses-options.js" @@ -170,12 +177,9 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe // Native namespaces hold only function tools, so deeper levels flatten into // the leaf names the same way non-native protocols flatten the whole tree. -const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* ( - tool: ToolEntry, - compatibility: Parameters[1], -) { +const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry, model: LanguageModel) { if (tool.type === "tool") - return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility)) + return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model)) // OpenAI requires a namespace description; fall back to a generic one so a // missing description never blocks the request. return { @@ -183,7 +187,7 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* ( name: tool.name, description: tool.description ?? `Tools in the ${tool.name} namespace.`, tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) => - OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, compatibility)), + OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, model)), ), } }) @@ -207,7 +211,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: )(request.providerOptions?.contextManagement) const options = OpenResponsesOptions.resolve(request) const updates = resolveEffortUpdates(request, options.reasoningEffort) - const toolSchemaCompatibility = request.model.compatibility?.toolSchema return yield* decodeBody({ ...(yield* OpenResponses.lowerConversation(updates.request, adapter)), ...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }), @@ -215,7 +218,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: tools: request.tools.length === 0 ? undefined - : yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)), + : yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, request.model)), tool_choice: request.tools.length === 0 ? undefined diff --git a/packages/ai/src/protocols/utils/gemini-tool-schema.ts b/packages/ai/src/protocols/utils/gemini-tool-schema.ts deleted file mode 100644 index b3c94de1213..00000000000 --- a/packages/ai/src/protocols/utils/gemini-tool-schema.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { isRecord } from "../../utils/record.js" - -// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a -// handful of common JSON Schema shapes. Keep this projection isolated so the -// Gemini protocol file still reads like the other protocol modules. -const SCHEMA_INTENT_KEYS = [ - "type", - "properties", - "items", - "prefixItems", - "enum", - "const", - "$ref", - "additionalProperties", - "patternProperties", - "required", - "not", - "if", - "then", - "else", -] - -const hasCombiner = (schema: unknown) => - isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) - -const hasSchemaIntent = (schema: unknown) => - isRecord(schema) && (hasCombiner(schema) || SCHEMA_INTENT_KEYS.some((key) => key in schema)) - -const sanitizeNode = (schema: unknown): unknown => { - if (!isRecord(schema)) return Array.isArray(schema) ? schema.map(sanitizeNode) : schema - - const result: Record = Object.fromEntries( - Object.entries(schema).map(([key, value]) => [ - key, - key === "enum" && Array.isArray(value) ? value.map(String) : sanitizeNode(value), - ]), - ) - - if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) result.type = "string" - - const properties = result.properties - if (result.type === "object" && isRecord(properties) && Array.isArray(result.required)) { - result.required = result.required.filter((field) => typeof field === "string" && field in properties) - } - - if (result.type === "array" && !hasCombiner(result)) { - result.items = result.items ?? {} - if (isRecord(result.items) && !hasSchemaIntent(result.items)) result.items = { ...result.items, type: "string" } - } - - if (typeof result.type === "string" && result.type !== "object" && !hasCombiner(result)) { - delete result.properties - delete result.required - } - - return result -} - -const emptyObjectSchema = (schema: Record) => - schema.type === "object" && - (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) && - !schema.additionalProperties - -const projectNode = (schema: unknown, nested = false): Record | undefined => { - if (!isRecord(schema)) return undefined - if (!nested && emptyObjectSchema(schema)) return undefined - const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined - const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined - const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false - const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf - const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined - const result = Object.fromEntries( - [ - ["description", schema.description], - ["required", schema.required], - ["format", schema.format], - ["type", types ? (types.length === 0 ? "null" : undefined) : schema.type], - [ - "nullable", - (Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf - ? true - : undefined, - ], - ["enum", schema.const !== undefined ? [schema.const] : schema.enum], - [ - "properties", - isRecord(schema.properties) - ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)])) - : undefined, - ], - [ - "items", - Array.isArray(schema.items) - ? schema.items.map((item) => projectNode(item, true)) - : schema.items === undefined - ? undefined - : projectNode(schema.items, true), - ], - ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined], - [ - "anyOf", - anyOfTypes - ? hasNullAnyOf && anyOfTypes.length === 1 - ? undefined - : anyOfTypes.map((item) => projectNode(item, true)) - : types && types.length > 0 - ? types.map((type) => ({ type })) - : undefined, - ], - ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined], - ["minLength", schema.minLength], - ].filter((entry) => entry[1] !== undefined), - ) - return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result -} - -export const convert = (schema: unknown) => projectNode(sanitizeNode(schema)) - -export * as GeminiToolSchema from "./gemini-tool-schema.js" diff --git a/packages/ai/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts index b6a2f71bb92..b8842e20d62 100644 --- a/packages/ai/src/protocols/utils/tool-schema.ts +++ b/packages/ai/src/protocols/utils/tool-schema.ts @@ -1,6 +1,6 @@ -import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../schema/index.js" +import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js" import { isRecord } from "../../utils/record.js" -import { GeminiToolSchema } from "./gemini-tool-schema.js" +import { GeminiJsonSchema } from "./gemini-json-schema.js" const tupleItemsSchema = (items: ReadonlyArray) => { const projected = items.map(moonshotNode) @@ -46,18 +46,29 @@ const moonshot = (schema: JsonSchema): JsonSchema => { const openAI = (schema: JsonSchema): JsonSchema => schema const responses = openAI -const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} +const gemini = GeminiJsonSchema.normalize +const MODEL_NAMES = [ + [/gemini/i, "gemini"], + [/kimi/i, "moonshot"], +] as const + +// An explicit `sanitizer` wins, and `none` opts out. Otherwise the protocol's own default +// applies (the Gemini API always uses Gemini's rules), then the model name selects the family's rules +// so models reached through gateways and OpenAI-compatible endpoints get the same handling. const modelCompatibility = ( schema: JsonSchema, - compatibility: LanguageModelToolSchemaCompatibility | undefined, + model: LanguageModel, + protocolDefault?: LanguageModelSanitizerCompatibility, ): JsonSchema => { - if (compatibility === undefined) return schema - switch (compatibility) { + switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) { case "gemini": return gemini(schema) case "moonshot": return moonshot(schema) + case "none": + case undefined: + return schema } } diff --git a/packages/ai/src/providers/moonshot.ts b/packages/ai/src/providers/moonshot.ts index 99b3bbdcc02..edaaf4e97b3 100644 --- a/packages/ai/src/providers/moonshot.ts +++ b/packages/ai/src/providers/moonshot.ts @@ -113,19 +113,19 @@ export const configure = (input: Config = {}) => { supportsStore: false, supportsStrictMode: false, supportsPromptCacheKey: true, - toolSchema: "moonshot", + sanitizer: "moonshot", reasoningField: "reasoning_content", }, }) const messages = (modelID: string | ModelID) => messagesRoute.with(defaults).model({ id: modelID, - compatibility: { requireSignature: false, toolSchema: "moonshot" }, + compatibility: { requireSignature: false, sanitizer: "moonshot" }, }) const responses = (modelID: string | ModelID) => responsesRoute .with(defaults) - .model({ id: modelID, compatibility: { toolSchema: "moonshot" } }) + .model({ id: modelID, compatibility: { sanitizer: "moonshot" } }) return { id, model: chat, chat, messages, responses, configure } } diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index cc0880bac22..c89a6f56a6a 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -152,8 +152,9 @@ export const ReasoningEffort = Schema.declare( { title: "ReasoningEffort" }, ) -export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) -export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type +/** Tool schema sanitizer for a model family. `none` opts out of the protocol and model-name defaults. */ +export const LanguageModelSanitizerCompatibility = Schema.Literals(["gemini", "moonshot", "none"]) +export type LanguageModelSanitizerCompatibility = Schema.Schema.Type export const LanguageModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"]) export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type< @@ -163,7 +164,7 @@ export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type< export class LanguageModelCompatibility extends Schema.Class( "LLM.LanguageModelCompatibility", )({ - toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility), + sanitizer: Schema.optional(LanguageModelSanitizerCompatibility), reasoningField: Schema.optional(Schema.String), /** Require every assistant message to include its reasoning field, even when empty. */ requireReasoning: Schema.optional(Schema.Boolean), diff --git a/packages/ai/test/llm.test.ts b/packages/ai/test/llm.test.ts index 408d451a4d1..4f5aca6f9ef 100644 --- a/packages/ai/test/llm.test.ts +++ b/packages/ai/test/llm.test.ts @@ -149,7 +149,7 @@ describe("llm constructors", () => { const updated = LanguageModel.update(base, { route: responsesRoute, defaults: { generation: { maxTokens: 20 } }, - compatibility: { toolSchema: "gemini", requireFinishReason: false }, + compatibility: { sanitizer: "gemini", requireFinishReason: false }, }) const updatedInput = LanguageModel.input(updated) @@ -157,7 +157,7 @@ describe("llm constructors", () => { expect(String(updated.id)).toBe("fake-model") expect(updated.route).toBe(responsesRoute) expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) - expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false }) + expect(updated.compatibility).toEqual({ sanitizer: "gemini", requireFinishReason: false }) expect(updatedInput.defaults).toBe(updated.defaults) expect(updatedInput.compatibility).toBe(updated.compatibility) expect(String(updatedInput.provider)).toBe("fake") @@ -172,14 +172,14 @@ describe("llm constructors", () => { providerOptions: { parallelToolCalls: false }, http: { body: { extra_body: true } }, }, - compatibility: { toolSchema: "moonshot" }, + compatibility: { sanitizer: "moonshot" }, }) const request = LLM.request({ model, prompt: "Say hello." }) expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false }) expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) - expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) + expect(request.model.compatibility).toEqual({ sanitizer: "moonshot" }) expect(request.generation).toBeUndefined() expect(request.providerOptions).toBeUndefined() expect(request.http).toBeUndefined() diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index 0bc198d9d55..85d3335fba8 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, Message, ToolCallPart } from "../../src/index.js" +import { LanguageModel, LLM, Message, ToolCallPart } from "../../src/index.js" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" @@ -414,6 +414,37 @@ describe("Google Vertex providers", () => { }), ) + it.effect("applies Gemini schema rules to the Gemini API and Gemini models unless opted out", () => + Effect.gen(function* () { + const vertex = { accessToken: "vertex-token", location: "us-central1", project: "vertex-project" } + const inputSchema = { type: "object", required: ["query", "missing"], properties: { query: { type: "string" } } } + const request = (model: Parameters[0]["model"]) => + compileRequest( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [{ name: "lookup", description: "Lookup.", inputSchema }], + }), + ) + + const normalized = { ...inputSchema, required: ["query"] } + const tunedModel = GoogleVertex.configure(vertex).model("endpoints/1234567890") + const tuned = yield* request(tunedModel) + expect(tuned.body.tools?.[0]?.functionDeclarations[0]?.parametersJsonSchema).toEqual(normalized) + const optedOut = yield* request(LanguageModel.update(tunedModel, { compatibility: { sanitizer: "none" } })) + expect(optedOut.body.tools?.[0]?.functionDeclarations[0]?.parametersJsonSchema).toEqual(inputSchema) + const geminiChat = yield* request(GoogleVertexChat.configure(vertex).model("google/gemini-3.8-flash")) + expect(geminiChat.body.tools?.[0]?.function.parameters).toEqual(normalized) + + const chat = yield* request(GoogleVertexChat.configure(vertex).model("deepseek-ai/deepseek-v3.2-maas")) + expect(chat.body.tools?.[0]?.function.parameters).toEqual(inputSchema) + const responses = yield* request(GoogleVertexResponses.configure(vertex).model("xai/grok-4.20-reasoning")) + expect(responses.body.tools?.[0]).toMatchObject({ parameters: inputSchema }) + const messages = yield* request(GoogleVertexMessages.configure(vertex).model("claude-sonnet-4-6")) + expect(messages.body.tools?.[0]).toMatchObject({ input_schema: inputSchema }) + }), + ) + it.effect("routes tuned Gemini models through their deployed endpoint", () => Effect.gen(function* () { const response = yield* LLMClient.generate( diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index ef12f0318eb..77e1698c84c 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -60,33 +60,51 @@ describe("tool schema projections", () => { }) }) - test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { - expect( - ToolSchemaProjection.gemini({ + it.effect("selects tool schema handling from the model name unless compatibility is explicit", () => + Effect.gen(function* () { + const route = OpenAIChat.route.with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("test"), + }) + const original = { type: "object", - required: ["status", "missing"], - properties: { - status: { type: "integer", enum: [1, 2] }, - tags: { type: "array" }, - name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, - }, - }), - ).toEqual({ - type: "object", - required: ["status"], - properties: { - status: { type: "string", enum: ["1", "2"] }, - tags: { type: "array", items: { type: "string" } }, - name: { type: "string" }, - }, - }) - }) + required: ["mode", "missing"], + properties: { mode: { enum: ["fast", "safe"] } }, + } + const parameters = (model: ReturnType) => + compileRequest( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [{ name: "lookup", description: "Lookup data.", inputSchema: original }], + }), + ).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters)) + const gemini = { ...original, required: ["mode"] } + const moonshot = { ...original, properties: { mode: { type: "string", enum: ["fast", "safe"] } } } + + expect(yield* parameters(route.model({ id: "google/Gemini-3.8-Flash" }))).toEqual(gemini) + expect( + yield* parameters(route.model({ id: "my-tuned-endpoint", compatibility: { sanitizer: "gemini" } })), + ).toEqual(gemini) + expect( + yield* parameters(route.model({ id: "google/gemini-3.8-flash", compatibility: { sanitizer: "moonshot" } })), + ).toEqual(moonshot) + expect(yield* parameters(route.model({ id: "moonshotai/Kimi-K3" }))).toEqual(moonshot) + expect( + yield* parameters(route.model({ id: "google/gemini-3.8-flash", compatibility: { sanitizer: "none" } })), + ).toEqual(original) + expect( + yield* parameters(route.model({ id: "moonshotai/Kimi-K3", compatibility: { sanitizer: "none" } })), + ).toEqual(original) + expect(yield* parameters(route.model({ id: "gpt-6-luna" }))).toEqual(original) + }), + ) it.effect("applies model compatibility without changing schema semantics", () => Effect.gen(function* () { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) - .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) + .model({ id: "kimi-k2", compatibility: { sanitizer: "moonshot" } }) const prepared = yield* compileRequest( LLM.request({ model,