Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gemini-json-schema-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-gemini': patch
---

Send Gemini function tool inputs through `parametersJsonSchema` so complete JSON Schema keywords reach the provider.
10 changes: 3 additions & 7 deletions packages/ai-gemini/src/tools/tool-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,11 +37,7 @@ export function convertToolsToProviderFormat<TTool extends Tool>(
}
assertUniqueToolNames(tools)
const result: Array<ToolUnion> = []
const functionDeclarations: Array<{
name: string
description?: string
parameters?: any
}> = []
const functionDeclarations: Array<FunctionDeclaration> = []

// Process each tool and group function declarations together
for (const tool of tools) {
Expand Down Expand Up @@ -80,7 +76,7 @@ export function convertToolsToProviderFormat<TTool extends Tool>(
functionDeclarations.push({
name: tool.name,
description: tool.description,
parameters: tool.inputSchema ?? {
parametersJsonSchema: tool.inputSchema ?? {
type: 'object',
properties: {},
required: [],
Expand Down
24 changes: 17 additions & 7 deletions packages/ai-gemini/tests/provider-tool-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
})
},
)
Expand Down
74 changes: 74 additions & 0 deletions testing/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -274,6 +281,73 @@ function geminiTTSMount(): Mountable {
}
}

function geminiJsonSchemaToolMount(): Mountable {
return {
async handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
pathname: string,
): Promise<boolean> {
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(
Expand Down
19 changes: 14 additions & 5 deletions testing/e2e/src/routes/api.arktype-tool-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }))
Expand All @@ -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.
Expand All @@ -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',
Expand Down
19 changes: 15 additions & 4 deletions testing/e2e/tests/arktype-tool-wire.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -63,4 +62,16 @@ test.describe('arktype — tool schema wire format', () => {
(captured?.function?.parameters?.['required'] as Array<string>) ?? [],
).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 })
})
})