Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 })
})
})
Loading