Compare commits

...
Author SHA1 Message Date
Aiden Cline 59aede8f57 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.
2026-09-23 21:16:41 -05:00
Aiden Cline 5a08f39f22 fix(ai): send Gemini tool schemas as parametersJsonSchema
The Gemini protocol projected tool schemas into Google's legacy OpenAPI
subset for the `parameters` field. The projection dropped $ref, $defs,
and additionalProperties, stringified enum values, and still produced
schemas Gemini rejects: Vertex (and the Zen gateway, which reaches Gemini
through Vertex) requires a type on every node, and the API rejects
properties on non-object types. Most of Vercel's MCP tools failed, and
accepted calls came back with wrong argument types ("3" for an integer
enum, JSON strings for referenced objects).

Send tool schemas in `parametersJsonSchema`, which accepts standard JSON
Schema, and rewrite only the shapes it still rejects: `required` entries
without a declared property, draft-04 boolean exclusive bounds, draft-07
tuple items, and recursive $ref loops that do not pass through an
optional property or potentially empty array items.
2026-09-23 20:16:50 -05:00
15 changed files with 275 additions and 285 deletions
@@ -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)
@@ -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<ToolDefinition>,
): 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,
+8 -33
View File
@@ -10,7 +10,6 @@ import {
LLMEvent,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
@@ -23,9 +22,8 @@ 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"
const ADAPTER = "gemini"
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
@@ -133,7 +131,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 +264,16 @@ 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
// =============================================================================
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema.
// `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,
parameters: GeminiToolSchema.convert(inputSchema),
parametersJsonSchema: GeminiJsonSchema.normalize(tool.inputSchema),
})
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -465,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,
@@ -491,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,
+1 -1
View File
@@ -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)
}),
+1 -6
View File
@@ -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) ??
+1 -2
View File
@@ -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,
),
+12 -9
View File
@@ -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<typeof ToolSchemaProjection.modelCompatibility>[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
@@ -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"
@@ -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<string, unknown> = 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<string, unknown>) =>
schema.type === "object" &&
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
!schema.additionalProperties
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | 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"
@@ -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<unknown>) => {
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
}
}
@@ -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"
}
}
]
+112 -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,68 @@ 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: {} },
},
})
}),
)
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" } },
})
}),
)
@@ -751,7 +816,7 @@ describe("Gemini route", () => {
{
name: "configure",
description: "Configure the operation",
parameters: {
parametersJsonSchema: {
type: "object",
required: ["options"],
properties: {
@@ -765,55 +830,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)
}),
)
+30 -20
View File
@@ -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<typeof route.model>) =>
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* () {