diff --git a/.changeset/gemini-json-schema-tools.md b/.changeset/gemini-json-schema-tools.md new file mode 100644 index 0000000000..fce07e3c7a --- /dev/null +++ b/.changeset/gemini-json-schema-tools.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-gemini': patch +--- + +Send Gemini function tool inputs through `parametersJsonSchema` so complete JSON Schema keywords reach the provider. diff --git a/packages/ai-gemini/src/tools/tool-converter.ts b/packages/ai-gemini/src/tools/tool-converter.ts index 12f9fd1768..a14f124795 100644 --- a/packages/ai-gemini/src/tools/tool-converter.ts +++ b/packages/ai-gemini/src/tools/tool-converter.ts @@ -8,7 +8,7 @@ import { convertGoogleSearchRetrievalToolToAdapterFormat } from './google-search import { convertGoogleSearchToolToAdapterFormat } from './google-search-tool' import { convertUrlContextToolToAdapterFormat } from './url-context-tool' import type { Tool } from '@tanstack/ai' -import type { ToolUnion } from '@google/genai' +import type { FunctionDeclaration, ToolUnion } from '@google/genai' /** * Converts standard Tool format to Gemini-specific tool format @@ -37,11 +37,7 @@ export function convertToolsToProviderFormat( } assertUniqueToolNames(tools) const result: Array = [] - const functionDeclarations: Array<{ - name: string - description?: string - parameters?: any - }> = [] + const functionDeclarations: Array = [] // Process each tool and group function declarations together for (const tool of tools) { @@ -80,7 +76,7 @@ export function convertToolsToProviderFormat( functionDeclarations.push({ name: tool.name, description: tool.description, - parameters: tool.inputSchema ?? { + parametersJsonSchema: tool.inputSchema ?? { type: 'object', properties: {}, required: [], diff --git a/packages/ai-gemini/tests/provider-tool-dispatch.test.ts b/packages/ai-gemini/tests/provider-tool-dispatch.test.ts index b84186b259..5469cfeff6 100644 --- a/packages/ai-gemini/tests/provider-tool-dispatch.test.ts +++ b/packages/ai-gemini/tests/provider-tool-dispatch.test.ts @@ -82,20 +82,30 @@ describe('Gemini provider tool dispatch', () => { it.each(PROVIDER_TOOL_NAMES)( 'keeps an ordinary function named %s as a function declaration', (name) => { + const inputSchema = { + type: 'object', + properties: { + query: { type: 'string' }, + unit: { const: 'celsius' }, + }, + required: ['query', 'unit'], + } const [converted] = convertToolsToProviderFormat([ { name, description: 'Run an application function', - inputSchema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'], - }, + inputSchema, } satisfies Tool, ]) - expect(converted).toMatchObject({ - functionDeclarations: [{ name }], + expect(converted).toEqual({ + functionDeclarations: [ + { + name, + description: 'Run an application function', + parametersJsonSchema: inputSchema, + }, + ], }) }, ) diff --git a/testing/e2e/global-setup.ts b/testing/e2e/global-setup.ts index 273873dbdf..fee7200876 100644 --- a/testing/e2e/global-setup.ts +++ b/testing/e2e/global-setup.ts @@ -55,6 +55,13 @@ export default async function globalSetup() { '/v1beta/models/gemini-3.1-flash-tts-preview:generateContent', geminiTTSMount(), ) + // aimock's Gemini handler reads `parameters`, so it cannot validate the raw + // `parametersJsonSchema` field. A model-specific mount keeps this check from + // intercepting the other Gemini chat tests. + mock.mount( + '/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent', + geminiJsonSchemaToolMount(), + ) // Gemini Veo video generation. aimock 1.29 mocks Gemini's `:predict` // (Imagen) endpoint but not the long-running `:predictLongRunning` + // operations-polling pair Veo uses, so mount both here. Non-Veo paths @@ -274,6 +281,73 @@ function geminiTTSMount(): Mountable { } } +function geminiJsonSchemaToolMount(): Mountable { + return { + async handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string, + ): Promise { + if (pathname !== '/' || req.method !== 'POST') return false + + const body = await readJsonRequestBody(req) + const tool = asRecord( + body && Array.isArray(body.tools) ? body.tools[0] : undefined, + ) + const declaration = asRecord( + Array.isArray(tool?.functionDeclarations) + ? tool.functionDeclarations[0] + : undefined, + ) + const schema = asRecord(declaration?.parametersJsonSchema) + const properties = asRecord(schema?.properties) + const unit = asRecord(properties?.unit) + + if ( + declaration?.name !== 'get_arktype_weather' || + declaration?.parameters !== undefined || + unit?.const !== 'celsius' + ) { + res.statusCode = 400 + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + error: { + code: 400, + message: 'Expected parametersJsonSchema with unit const.', + status: 'INVALID_ARGUMENT', + }, + }), + ) + return true + } + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/event-stream') + res.end( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'Schema accepted' }], + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + })}\n\n`, + ) + return true + }, + } +} + function grokSTTMount(): Mountable { return { async handleRequest( diff --git a/testing/e2e/src/routes/api.arktype-tool-wire.ts b/testing/e2e/src/routes/api.arktype-tool-wire.ts index f0c9c38370..44863e9bbe 100644 --- a/testing/e2e/src/routes/api.arktype-tool-wire.ts +++ b/testing/e2e/src/routes/api.arktype-tool-wire.ts @@ -3,6 +3,7 @@ import { chat, createChatOptions, toolDefinition } from '@tanstack/ai' import { createOpenRouterText } from '@tanstack/ai-openrouter' import { HTTPClient } from '@openrouter/sdk' import { type } from 'arktype' +import { createTextAdapter } from '@/lib/providers' const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' const DUMMY_KEY = 'sk-e2e-test-dummy-key' @@ -15,16 +16,16 @@ const DUMMY_KEY = 'sk-e2e-test-dummy-key' * function fell through and, once serialized to the wire, the tool's * `parameters` collapsed to `{}` (functions don't survive `JSON.stringify`). * - * This route drives the OpenRouter chat adapter with an ArkType-schema - * function tool against aimock so the companion spec can inspect aimock's - * journal (`GET /v1/_requests`) and assert the converted JSON Schema actually - * crossed the wire. The model response is irrelevant to the assertion. + * This route drives OpenRouter and Gemini with an ArkType-schema function + * tool. The companion spec checks that the converted JSON Schema crosses each + * provider wire. */ const arktypeWeatherTool = toolDefinition({ name: 'get_arktype_weather', description: 'Get weather for a city (ArkType-schema tool, #276 wire test)', inputSchema: type({ city: 'string', + unit: "'celsius'", 'units?': "'celsius' | 'fahrenheit'", }), }).server(async () => JSON.stringify({ ok: true })) @@ -35,6 +36,7 @@ export const Route = createFileRoute('/api/arktype-tool-wire')({ POST: async ({ request }) => { const url = new URL(request.url) const testId = url.searchParams.get('testId') ?? undefined + const isGemini = url.searchParams.get('provider') === 'gemini' // Same X-Test-Id injection pattern as the other wire specs so this // route gets its own aimock test bucket. @@ -54,7 +56,14 @@ export const Route = createFileRoute('/api/arktype-tool-wire')({ try { for await (const _ of chat({ - ...createChatOptions({ adapter }), + ...(isGemini + ? createTextAdapter( + 'gemini', + 'gemini-2.5-flash-lite', + undefined, + testId, + ) + : createChatOptions({ adapter })), messages: [ { role: 'user', diff --git a/testing/e2e/tests/arktype-tool-wire.spec.ts b/testing/e2e/tests/arktype-tool-wire.spec.ts index fe194db276..a7da415629 100644 --- a/testing/e2e/tests/arktype-tool-wire.spec.ts +++ b/testing/e2e/tests/arktype-tool-wire.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures' /** - * Wire-format regression for #276 — ArkType schemas. + * Wire-format regressions for ArkType schemas. * * ArkType's `type()` returns a callable function (with `~standard` attached), * not a plain object. `@tanstack/ai`'s schema-detection guards previously @@ -11,9 +11,8 @@ import { test, expect } from './fixtures' * tool's `parameters` collapsed to `{}` (functions don't survive * `JSON.stringify`). * - * This spec drives `/api/arktype-tool-wire` (OpenRouter chat adapter, ArkType - * function tool) and inspects aimock's journal (`GET /v1/_requests`) to assert - * the converted JSON Schema actually reached the provider. + * The OpenRouter case inspects aimock's journal. The Gemini case uses a raw + * mock mount because aimock normalizes Gemini function declarations. */ test.describe('arktype — tool schema wire format', () => { test.beforeEach(async ({ request, aimockPort }) => { @@ -63,4 +62,16 @@ test.describe('arktype — tool schema wire format', () => { (captured?.function?.parameters?.['required'] as Array) ?? [], ).toContain('city') }) + + test('Gemini preserves ArkType JSON Schema keywords on the wire', async ({ + request, + testId, + }) => { + const res = await request.post( + `/api/arktype-tool-wire?provider=gemini&testId=${encodeURIComponent(testId)}`, + ) + expect(res.ok()).toBe(true) + const result = (await res.json()) as { ok: boolean; error?: string } + expect(result, result.error).toMatchObject({ ok: true }) + }) })