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* () {