diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 46b7210ffa7..4ed948872df 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -23,7 +23,7 @@ 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 { GeminiToolSchema } from "./utils/gemini-tool-schema.js" +import { GeminiJsonSchema } from "./utils/gemini-json-schema.js" import { Lifecycle } from "./utils/lifecycle.js" import { ToolSchemaProjection } from "./utils/tool-schema.js" @@ -133,7 +133,7 @@ const GeminiSystemInstruction = Schema.Struct({ const GeminiFunctionDeclaration = Schema.Struct({ name: Schema.String, description: Schema.String, - parameters: Schema.optional(JsonObject), + parametersJsonSchema: JsonObject, }) const GeminiTool = Schema.Struct({ @@ -266,36 +266,15 @@ interface ParserState { readonly seenCallIds?: ReadonlySet } -// ============================================================================= -// Tool Schema Conversion -// ============================================================================= -// Tool-schema conversion has two distinct concerns: -// -// 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number -// enums (must be strings), `required` entries that don't match a property, -// untyped arrays (`items` must be present), and `properties`/`required` -// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules. -// -// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect: -// drop empty root parameter schemas while preserving nested empty objects, -// expand type arrays into `anyOf`, derive `nullable: true` from null members, -// coerce `const` to `[const]` enum, recurse properties/items, and propagate -// only an allowlisted set of keys (description, required, format, type, -// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength). -// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is -// silently dropped. -// -// Sanitize runs first, then project. The implementation lives in -// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other -// provider protocols. - // ============================================================================= // 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) => ({ name: tool.name, description: tool.description, - parameters: GeminiToolSchema.convert(inputSchema), + parametersJsonSchema: GeminiJsonSchema.normalize(inputSchema), }) const lowerToolConfig = (toolChoice: NonNullable) => diff --git a/packages/ai/src/protocols/utils/gemini-json-schema.ts b/packages/ai/src/protocols/utils/gemini-json-schema.ts new file mode 100644 index 00000000000..d9af7870a9d --- /dev/null +++ b/packages/ai/src/protocols/utils/gemini-json-schema.ts @@ -0,0 +1,77 @@ +import type { JsonSchema } from "../../schema/index.js" +import { isRecord } from "../../utils/record.js" + +// Gemini's `parametersJsonSchema` accepts standard JSON Schema, but rejects a few shapes that +// published tool schemas commonly contain. Rewrite only those and send everything else unchanged. +const SCHEMA_MAPS = new Set([ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", +]) +const VALUES = new Set(["const", "default", "enum", "examples", "dependentRequired"]) + +const mapValues = (record: Record, map: (value: unknown, key: string) => unknown) => + Object.fromEntries(Object.entries(record).map(([key, value]) => [key, map(value, key)])) + +const normalizeNode = (schema: unknown): unknown => { + if (Array.isArray(schema)) return schema.map(normalizeNode) + if (!isRecord(schema)) return schema + const properties = isRecord(schema.properties) ? schema.properties : undefined + return Object.fromEntries( + Object.entries(schema).flatMap(([key, value]) => { + if (VALUES.has(key)) return [[key, value]] + if (SCHEMA_MAPS.has(key) && isRecord(value)) return [[key, mapValues(value, normalizeNode)]] + // `required` may only name declared properties. + if (key === "required" && properties && Array.isArray(value)) + return [[key, value.filter((name) => typeof name === "string" && Object.hasOwn(properties, name))]] + // Draft-04 boolean exclusive bounds become the numeric form. + if (key === "exclusiveMinimum" && typeof value === "boolean") + return value && typeof schema.minimum === "number" ? [[key, schema.minimum]] : [] + if (key === "exclusiveMaximum" && typeof value === "boolean") + return value && typeof schema.maximum === "number" ? [[key, schema.maximum]] : [] + if (key === "minimum" && schema.exclusiveMinimum === true) return [] + if (key === "maximum" && schema.exclusiveMaximum === true) return [] + // Draft-07 tuples (`items` array plus `additionalItems`) are `prefixItems` plus `items` in 2020-12. + if (key === "items" && Array.isArray(value)) return [["prefixItems", value.map(normalizeNode)]] + if (key === "additionalItems" && Array.isArray(schema.items)) return [["items", normalizeNode(value)]] + return [[key, normalizeNode(value)]] + }), + ) +} + +// Gemini accepts a recursive `$ref` only when the loop passes through an optional property or +// potentially empty array `items`. Replace other self-references with an unconstrained schema. +const cutLoops = (schema: unknown, target: string, safe: boolean): unknown => { + if (Array.isArray(schema)) return schema.map((item) => cutLoops(item, target, safe)) + if (!isRecord(schema)) return schema + if (schema.$ref === target && !safe) return {} + const required = Array.isArray(schema.required) ? schema.required : [] + return mapValues(schema, (value, key) => { + if (VALUES.has(key)) return value + if (key === "items") return cutLoops(value, target, safe || !(Number(schema.minItems) > 0)) + if (key === "properties" && isRecord(value)) + return mapValues(value, (child, name) => cutLoops(child, target, safe || !required.includes(name))) + if (SCHEMA_MAPS.has(key) && isRecord(value)) return mapValues(value, (child) => cutLoops(child, target, safe)) + return cutLoops(value, target, safe) + }) +} + +export const normalize = (schema: JsonSchema): JsonSchema => { + const normalized = normalizeNode(schema) + if (!isRecord(normalized)) return {} + const result = cutLoops( + mapValues(normalized, (value, key) => + (key === "$defs" || key === "definitions") && isRecord(value) + ? mapValues(value, (def, name) => cutLoops(def, `#/${key}/${name}`, false)) + : value, + ), + "#", + false, + ) + return isRecord(result) ? result : {} +} + +export * as GeminiJsonSchema from "./gemini-json-schema.js" diff --git a/packages/ai/test/fixtures/recordings/google-vertex/calls-a-tool.json b/packages/ai/test/fixtures/recordings/google-vertex/calls-a-tool.json index 123538a19c2..dab3321603d 100644 --- a/packages/ai/test/fixtures/recordings/google-vertex/calls-a-tool.json +++ b/packages/ai/test/fixtures/recordings/google-vertex/calls-a-tool.json @@ -7,7 +7,7 @@ "protocol:gemini" ], "name": "google-vertex/calls-a-tool", - "recordedAt": "2026-09-22T03:46:29.725Z" + "recordedAt": "2026-09-24T01:06:19.246Z" }, "interactions": [ { @@ -18,14 +18,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}" + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parametersJsonSchema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}]}]}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream" }, - "body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_28936\"},\"thoughtSignature\": \"AY89a1/MVeBxtwu9l/96clFobOrrd7Q5MTf5o8/A22fc++1EsFYVeCFx9WCGmJ+D3yW3FWlRXKtJECYfprSoxixCSOYBNiV9g8/IkejbAxF26k1vxTsHkoqts0O2s8GqqBSRAavJfKQ/taTRbPuIq+b+RKZ8SDJFoWDnFjXpdS5S164uJTjmgs4ALd8oB1oQRycMsTbAY51TmkcNUnhARpjKQfqiizz4KfSRFjx7xDnza0v4jj73m2GV86eLDhVT7trIT+Dl5DsJt6RBLOE2Amse54mcpIbsYaANlY1YDU6FKuUBqnuwpl0OfhMXwWuYMogv1tH/0rTXsqNwmHyt/b9ZZXqYCdFXpMGt8udmoZ0d\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 93,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 38},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\n" + "body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_554608\"},\"thoughtSignature\": \"AY89a19hRAmVFgUw7vo3hvQF7VpSnCGYSRXo34+yjCySmgHtvL/4LEf6V2F12glreaWUixVINDFWR+DLprZZxayR8gb+wZMWssV0Cbv1fDPYV6qxggRiLEKnG6A7UY+vmhXUO5IAtL06Ud24IYd+3QI0T05gzSnwqy7Uotw9J5B8LSr76MvbDncgyeWcBhZSpxxi2XPJxZqR6N/7gwxRSW3KMBp0CKHnU4MF+t0fN1oQ2uSWCxQAYW0nq8TQL6nYYgShN35VeGJ5+UvJ8OIYC0COQ1u/VNdqmJs2uRs3dHGzXzDSWdb/lIcqtzyKRLsIa7RWYmF1xZhEQZkXGGf9yco8e1OOovYhR/cEwE2+aw1scl3Deg2+D2R1PiFwwoZuMV+lIj3IwNexjLd56ZO/7QpzaX2K97b11duH4mBjgfG0x7c3+r9raD17quokhgW1ffoPiXiaemvD6mA84kkDIc4jxSnwNwuVxTgUkEwswub1C1lrlnzzeLHjnMvdopdHIViDsdcfRnXkhIwGyvr+QBSuz7yF1V/Vw+9MxYVVNM/7eQPpMxjJHpHTBMWqtzkC9zE6wK2iWr49AJm8ZpMS8a9uhW0OAksUslv5gvWKDEecaufO/66w+xltgnvDh2NVJuthULuG+nWnxGRq4ZbX00s3p0/J1vqQZcLsi37RpAs75g==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-24T01:06:18.093130Z\",\"responseId\": \"ine0asrXBbui3dAPpNDS8QE\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 156,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 101},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-24T01:06:18.093130Z\",\"responseId\": \"ine0asrXBbui3dAPpNDS8QE\"}\r\n\r\n" } } ] diff --git a/packages/ai/test/fixtures/recordings/google-vertex/continues-after-a-tool-result.json b/packages/ai/test/fixtures/recordings/google-vertex/continues-after-a-tool-result.json index 2525fc094f8..bf5b894ef19 100644 --- a/packages/ai/test/fixtures/recordings/google-vertex/continues-after-a-tool-result.json +++ b/packages/ai/test/fixtures/recordings/google-vertex/continues-after-a-tool-result.json @@ -7,7 +7,7 @@ "protocol:gemini" ], "name": "google-vertex/continues-after-a-tool-result", - "recordedAt": "2026-09-22T03:46:30.691Z" + "recordedAt": "2026-09-24T01:06:20.321Z" }, "interactions": [ { @@ -18,14 +18,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}" + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parametersJsonSchema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}]}]}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream" }, - "body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18aVI8Uxr2VaOI822u0DyuQdy4B00uhLnjqYb5Qb6Mkscccm018knLtYThB5UX8dRv1VFsORSQ0Qo6Gx9RCng2AK9EPce7p\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 74,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}]},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\n" + "body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-24T01:06:19.664329Z\",\"responseId\": \"i3e0aonGKPCnrb8Pm97yiQs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-24T01:06:19.664329Z\",\"responseId\": \"i3e0aonGKPCnrb8Pm97yiQs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a19mRwbMV0dQccZShfDptLQ20vI7Rg/LSyPDwW1Wt62fQvRT/2nUWW13cMmgYbd2VGGtrZIbnj+hxHMZt1NK2RyJIkg/FB13\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 74,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}]},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-24T01:06:19.664329Z\",\"responseId\": \"i3e0aonGKPCnrb8Pm97yiQs\"}\r\n\r\n" } } ] diff --git a/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json b/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json index 729958333c7..ee8defabbe5 100644 --- a/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json +++ b/packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json @@ -1,9 +1,16 @@ { "version": 1, "metadata": { - "tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"], + "tags": [ + "prefix:pdf", + "pdf", + "provider:google", + "protocol:gemini", + "tool", + "tool-result" + ], "name": "pdf/gemini-tool-result", - "recordedAt": "2026-07-22T18:21:59.606Z" + "recordedAt": "2026-09-24T01:06:23.498Z" }, "interactions": [ { @@ -14,14 +21,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parametersJsonSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"],\"additionalProperties\":false}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream" }, - "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n" + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"call_475949\"},\"thoughtSignature\": \"Et4CCtsCAWkUfRPfQWeGIEQ/AhexY97IZoK8aNLr2EbW3nufnAXpa1y74z7qH1Tdk20j5EC57BSBbdy5u6xFHNomzZhGFVmdVlhgZFXGSO8F8K4t8rqQWUJZSDH4dFaC95KpUTfp5CAMepbOuc/dGLgXfVsSV0e/HvUcdWKoEz8Jlsgk59nexqSsdl/kGzuuIeW6zS5r8JHus+PfRzvSgua4B9OpDU9WWPCJZu93NLbXJELbZ330h7CP+uxXoo1Puu0KSrvLman8Geoc0nagMx895L0gO3CsbB+PpijQ6Pt6YAaiv1V2MeOCUKCYUpk+77MjC6RnsTFTRaJYJ5PPkniuRKedp/DF1aMj7nGxQ6BpHOMq9mjw2zlH3I2kJi1wqm+amVz30vTHa31xTH88w+IUc1yNdLAzdVJV+/+0mqw8bLweSjSHFxZN0NIv6aczAIS5stoGobNDO4bRGDETH3Q=\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 152,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"jHe0apWcM92aqtsP8t2LyAg\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 152,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"jHe0apWcM92aqtsP8t2LyAg\"}\r\n\r\n" } }, { @@ -32,14 +39,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"call_475949\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"Et4CCtsCAWkUfRPfQWeGIEQ/AhexY97IZoK8aNLr2EbW3nufnAXpa1y74z7qH1Tdk20j5EC57BSBbdy5u6xFHNomzZhGFVmdVlhgZFXGSO8F8K4t8rqQWUJZSDH4dFaC95KpUTfp5CAMepbOuc/dGLgXfVsSV0e/HvUcdWKoEz8Jlsgk59nexqSsdl/kGzuuIeW6zS5r8JHus+PfRzvSgua4B9OpDU9WWPCJZu93NLbXJELbZ330h7CP+uxXoo1Puu0KSrvLman8Geoc0nagMx895L0gO3CsbB+PpijQ6Pt6YAaiv1V2MeOCUKCYUpk+77MjC6RnsTFTRaJYJ5PPkniuRKedp/DF1aMj7nGxQ6BpHOMq9mjw2zlH3I2kJi1wqm+amVz30vTHa31xTH88w+IUc1yNdLAzdVJV+/+0mqw8bLweSjSHFxZN0NIv6aczAIS5stoGobNDO4bRGDETH3Q=\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"call_475949\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parametersJsonSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"],\"additionalProperties\":false}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream" }, - "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n" + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"jXe0ar7CNdjBqtsPqMm2oAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EtcCCtQCAWkUfRM3dQjvavztF1qActLo1mpXoGlWdQ+qUpKf1DdrmgWIT1nm70cm+yGf4pj6UU9fhKU+/y+ScUv1IJrb3c3yU5X2/CGzcN/DeRhxuvuXRaGxtWi4GIu2nFr3oJrAOnJnh/kosgiqixwQ3EK6/0jdE2HOqxu7jImvozaVcR9OiVGgAHRwmDYtB6K+6/jMVjORBKB4nZp8MTb3IWaiN6soO+FiWRgl3etnnJhkJb6eBHhHmLW8qxyOmVwRII3Gtm5jJbxGND/aZSpxlBvfBpGfbJQJIfNQDs+sXeQpuZh8SZUVRgVAZyOrqbVS1vRq8kODaBvDHKuTr0Qo9RpGGGo0npaYooP663omHDhm7Uu5plFwAVYR5G4z11my6efbHE1WvbcCgg6deqMFf6BX0SzzgPovB3p+giuHVwjD7gyYS3sdw/txxYPkY3+H/gWchf0FGw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 696,\"candidatesTokenCount\": 8,\"totalTokenCount\": 757,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 176}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"jXe0ar7CNdjBqtsPqMm2oAs\"}\r\n\r\n" } } ] diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 0af3cef416e..52491083e2c 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -381,7 +381,7 @@ describe("Gemini route", () => { { name: "lookup", description: "Lookup data", - parameters: { type: "object", properties: { query: { type: "string" } } }, + parametersJsonSchema: { type: "object", properties: { query: { type: "string" } } }, }, ], }, @@ -676,7 +676,7 @@ describe("Gemini route", () => { }), ) - it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => + it.effect("normalizes the JSON Schema shapes Gemini rejects", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ @@ -689,11 +689,32 @@ describe("Gemini route", () => { description: "Lookup data", inputSchema: { type: "object", - required: ["status", "missing"], + required: ["ttl", "items", "parent", "missing"], properties: { - status: { type: "integer", enum: [1, 2] }, - tags: { type: "array" }, - name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + ttl: { type: "number", minimum: 0, exclusiveMinimum: true, maximum: 60, exclusiveMaximum: false }, + pair: { type: "array", items: [{ type: "string" }, { type: "integer" }], additionalItems: false }, + items: { type: "array", items: { type: "string" } }, + required: { type: "boolean" }, + params: { $ref: "#/$defs/Json" }, + tree: { $ref: "#/$defs/Node" }, + chain: { $ref: "#/$defs/Link" }, + batch: { $ref: "#/$defs/Batch" }, + self: { $ref: "#" }, + parent: { $ref: "#" }, + cart: { type: "object", default: { items: ["a"] } }, + }, + dependentRequired: { items: ["ttl"] }, + $defs: { + Json: { + anyOf: [ + { type: "string" }, + { type: "array", items: { $ref: "#/$defs/Json" } }, + { type: "object", additionalProperties: { $ref: "#/$defs/Json" } }, + ], + }, + Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" } } }, + Link: { type: "object", required: ["next"], properties: { next: { $ref: "#/$defs/Link" } } }, + Batch: { type: "array", minItems: 1, items: { $ref: "#/$defs/Batch" } }, }, }, }, @@ -701,24 +722,35 @@ describe("Gemini route", () => { }), ) - expect(prepared.body).toMatchObject({ - tools: [ - { - functionDeclarations: [ - { - parameters: { - type: "object", - required: ["status"], - properties: { - status: { type: "string", enum: ["1", "2"] }, - tags: { type: "array", items: { type: "string" } }, - name: { type: "string" }, - }, - }, - }, + expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parametersJsonSchema).toEqual({ + type: "object", + required: ["ttl", "items", "parent"], + properties: { + ttl: { type: "number", exclusiveMinimum: 0, maximum: 60 }, + pair: { type: "array", prefixItems: [{ type: "string" }, { type: "integer" }], items: false }, + items: { type: "array", items: { type: "string" } }, + required: { type: "boolean" }, + params: { $ref: "#/$defs/Json" }, + tree: { $ref: "#/$defs/Node" }, + chain: { $ref: "#/$defs/Link" }, + batch: { $ref: "#/$defs/Batch" }, + self: { $ref: "#" }, + parent: {}, + cart: { type: "object", default: { items: ["a"] } }, + }, + dependentRequired: { items: ["ttl"] }, + $defs: { + Json: { + anyOf: [ + { type: "string" }, + { type: "array", items: { $ref: "#/$defs/Json" } }, + { type: "object", additionalProperties: {} }, ], }, - ], + Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" } } }, + Link: { type: "object", required: ["next"], properties: { next: {} } }, + Batch: { type: "array", minItems: 1, items: {} }, + }, }) }), ) @@ -751,7 +783,7 @@ describe("Gemini route", () => { { name: "configure", description: "Configure the operation", - parameters: { + parametersJsonSchema: { type: "object", required: ["options"], properties: { @@ -765,55 +797,38 @@ describe("Gemini route", () => { }), ) - it.effect("projects Gemini type arrays without narrowing their allowed values", () => + it.effect("sends standard JSON Schema tool parameters unchanged", () => Effect.gen(function* () { + const inputSchema = { + type: "object", + required: ["level"], + properties: { + level: { type: "integer", enum: [1, 2, 3] }, + status: { type: ["number", "string"], description: "Status filter" }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + kind: { const: "fixed" }, + mode: { enum: ["fast", "safe"] }, + tags: { type: "array" }, + env: { type: "object", additionalProperties: { type: "string" } }, + action: { + oneOf: [ + { type: "object", properties: { type: { const: "move" }, to: { type: "string" } } }, + { type: "object", properties: { type: { const: "delete" } } }, + ], + }, + item: { $ref: "#/$defs/Item" }, + }, + $defs: { Item: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, + } const prepared = yield* compileRequest( LLM.request({ model, prompt: "Use the tool.", - tools: [ - { - name: "filter", - description: "Filter values", - inputSchema: { - type: "object", - properties: { - status: { type: ["number", "string"], description: "Status filter" }, - maybe: { type: ["string", "null"] }, - nothing: { type: ["null"] }, - explicit: { anyOf: [{ type: "string" }, { type: "null" }] }, - choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] }, - }, - }, - }, - ], + tools: [{ name: "filter", description: "Filter values", inputSchema }], }), ) - expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({ - type: "object", - properties: { - status: { - description: "Status filter", - anyOf: [{ type: "number" }, { type: "string" }], - }, - maybe: { - nullable: true, - anyOf: [{ type: "string" }], - }, - nothing: { - type: "null", - }, - explicit: { - type: "string", - nullable: true, - }, - choice: { - anyOf: [{ type: "string" }, { type: "number" }], - nullable: true, - }, - }, - }) + expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parametersJsonSchema).toEqual(inputSchema) }), )