fix(ai): send Gemini tool schemas as parametersJsonSchema (#51009)

This commit is contained in:
Aiden Cline
2026-09-23 20:28:33 -05:00
committed by GitHub
parent 8d5eb3bf9c
commit 88a9688c9c
6 changed files with 180 additions and 102 deletions
+5 -26
View File
@@ -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<string>
}
// =============================================================================
// 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<LLMRequest["toolChoice"]>) =>
@@ -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<string, unknown>, 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"
@@ -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"
}
}
]
@@ -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"
}
}
]
@@ -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"
}
}
]
+79 -64
View File
@@ -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)
}),
)