From 59aede8f57b4d542c990d122cd87e4c78eb89a64 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 23 Sep 2026 21:16:41 -0500 Subject: [PATCH] fix(ai): apply Gemini schema handling to Gemini on any route The toolSchema "gemini" mode still used the legacy OpenAPI projection, and nothing enabled it, so Gemini reached through OpenAI-compatible endpoints and gateways got schemas unchanged. Point the mode at the JSON Schema normalizer, delete the old projection, and select the mode from the model name when no tool schema mode is set explicitly. The native Gemini protocol always applies the normalizer, including for tuned endpoints whose IDs do not name Gemini. --- .../ai/src/protocols/anthropic-messages.ts | 7 +- packages/ai/src/protocols/bedrock-converse.ts | 8 +- packages/ai/src/protocols/gemini.ts | 14 +-- packages/ai/src/protocols/meta-responses.ts | 2 +- packages/ai/src/protocols/open-responses.ts | 7 +- packages/ai/src/protocols/openai-chat.ts | 3 +- packages/ai/src/protocols/openai-responses.ts | 21 ++-- .../src/protocols/utils/gemini-tool-schema.ts | 119 ------------------ .../ai/src/protocols/utils/tool-schema.ts | 18 +-- packages/ai/test/provider/gemini.test.ts | 33 +++++ .../ai/test/tool-schema-projection.test.ts | 50 +++++--- 11 files changed, 97 insertions(+), 185 deletions(-) delete mode 100644 packages/ai/src/protocols/utils/gemini-tool-schema.ts 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..7504068a473 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -10,7 +10,6 @@ import { LLMEvent, Usage, type FinishReason, - type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -25,7 +24,6 @@ import { JsonObject, knownString, lenient, optionalArray, optionalNull, Provider 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" const ADAPTER = "gemini" // Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost. @@ -270,11 +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) => ({ +// `utils/gemini-json-schema` rewrites only the few shapes Gemini still rejects. This +// protocol always applies it, including for tuned endpoints whose IDs do not name Gemini. +const lowerTool = (tool: ToolDefinition) => ({ name: tool.name, description: tool.description, - parametersJsonSchema: GeminiJsonSchema.normalize(inputSchema), + parametersJsonSchema: GeminiJsonSchema.normalize(tool.inputSchema), }) 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(lowerTool), }, ] : 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..3acfaa38894 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 } 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,18 @@ 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 modelCompatibility = ( - schema: JsonSchema, - compatibility: LanguageModelToolSchemaCompatibility | undefined, -): JsonSchema => { - if (compatibility === undefined) return schema - switch (compatibility) { +// An explicit compatibility setting wins. Otherwise a Gemini model name selects Gemini's rules, +// so Gemini reached through gateways and OpenAI-compatible endpoints gets the same schema handling. +const modelCompatibility = (schema: JsonSchema, model: LanguageModel): JsonSchema => { + switch (model.compatibility?.toolSchema ?? (/gemini/i.test(model.id) ? "gemini" : undefined)) { case "gemini": return gemini(schema) case "moonshot": return moonshot(schema) + case undefined: + return schema } } diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 52491083e2c..2ff23ea509c 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -755,6 +755,39 @@ describe("Gemini route", () => { }), ) + it.effect("normalizes tool schemas for tuned endpoints whose IDs do not name Gemini", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: Gemini.route + .with({ + endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, + auth: Auth.header("x-goog-api-key", "test"), + }) + .model({ id: "projects/p/locations/global/endpoints/123" }), + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { + type: "object", + required: ["query", "missing"], + properties: { query: { type: "string" } }, + }, + }, + ], + }), + ) + + expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parametersJsonSchema).toEqual({ + type: "object", + required: ["query"], + properties: { query: { type: "string" } }, + }) + }), + ) + it.effect("preserves nested empty object tool schemas", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index ef12f0318eb..2ea477c4244 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -60,27 +60,37 @@ describe("tool schema projections", () => { }) }) - test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { - expect( - ToolSchemaProjection.gemini({ + it.effect("selects Gemini 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"] } + + expect(yield* parameters(route.model({ id: "google/Gemini-3.8-Flash" }))).toEqual(gemini) + expect( + yield* parameters(route.model({ id: "my-tuned-endpoint", compatibility: { toolSchema: "gemini" } })), + ).toEqual(gemini) + expect( + yield* parameters(route.model({ id: "google/gemini-3.8-flash", compatibility: { toolSchema: "moonshot" } })), + ).toEqual({ ...original, properties: { mode: { type: "string", enum: ["fast", "safe"] } } }) + expect(yield* parameters(route.model({ id: "gpt-6-luna" }))).toEqual(original) + }), + ) it.effect("applies model compatibility without changing schema semantics", () => Effect.gen(function* () {