Skip to content

Commit ab1e4f0

Browse files
committed
🐛 Fix gemini models when web grounding is on
1 parent 7449524 commit ab1e4f0

2 files changed

Lines changed: 197 additions & 69 deletions

File tree

src/lib/llm/providers/google.ts

Lines changed: 40 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,35 @@ import {
3737
*
3838
* Unsupported formats (like .docx, .xlsx) are converted to text via mammoth.
3939
*/
40+
/**
41+
* Recursively strip JSON Schema properties unsupported by the Gemini native API
42+
* (e.g. additionalProperties). This prevents 400 errors when sending tool
43+
* definitions through the native generateContent / streamGenerateContent endpoints.
44+
*/
45+
export function sanitizeSchemaForGemini(
46+
schema: Record<string, unknown>,
47+
): Record<string, unknown> {
48+
const UNSUPPORTED_KEYS = ['additionalProperties']
49+
50+
const result: Record<string, unknown> = {}
51+
for (const [key, value] of Object.entries(schema)) {
52+
if (UNSUPPORTED_KEYS.includes(key)) continue
53+
54+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
55+
result[key] = sanitizeSchemaForGemini(value as Record<string, unknown>)
56+
} else if (Array.isArray(value)) {
57+
result[key] = value.map((item) =>
58+
item !== null && typeof item === 'object' && !Array.isArray(item)
59+
? sanitizeSchemaForGemini(item as Record<string, unknown>)
60+
: item,
61+
)
62+
} else {
63+
result[key] = value
64+
}
65+
}
66+
return result
67+
}
68+
4069
export class GoogleProvider implements LLMProviderInterface {
4170
protected baseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
4271
protected nativeBaseUrl = 'https://generativelanguage.googleapis.com/v1beta'
@@ -259,31 +288,16 @@ export class GoogleProvider implements LLMProviderInterface {
259288
const { contents, systemInstruction } =
260289
await this.convertMessagesToNativeFormat(messages)
261290

262-
// Build tools array: google_search + custom function declarations
263-
const toolsArray: Record<string, unknown>[] = [{ google_search: {} }]
264-
265-
// Include custom function tools alongside Google Search grounding
266-
if (config?.tools && config.tools.length > 0) {
267-
const functionDeclarations = config.tools
268-
.filter((t) => t.type === 'function' && t.function)
269-
.map((t) => ({
270-
name: t.function.name,
271-
description: t.function.description,
272-
parameters: t.function.parameters,
273-
}))
274-
if (functionDeclarations.length > 0) {
275-
toolsArray.push({ function_declarations: functionDeclarations })
276-
}
277-
}
278-
279-
// Build request body with google_search + custom tools
291+
// Only google_search for grounded requests.
292+
// Combining google_search with function_declarations is only supported
293+
// in the Live API — the regular generateContent endpoint rejects it.
280294
const requestBody: Record<string, unknown> = {
281295
contents,
282296
generationConfig: {
283297
temperature: config?.temperature || 0.7,
284298
maxOutputTokens: config?.maxTokens,
285299
},
286-
tools: toolsArray,
300+
tools: [{ google_search: {} }],
287301
}
288302

289303
if (systemInstruction) {
@@ -315,28 +329,12 @@ export class GoogleProvider implements LLMProviderInterface {
315329
throw new Error('No response candidate from Gemini API')
316330
}
317331

318-
// Extract text content and function calls from parts
332+
// Extract text content from parts (no function calls in grounding-only mode)
319333
let textContent = ''
320-
const tool_calls: Array<{
321-
id: string
322-
type: 'function'
323-
function: { name: string; arguments: string }
324-
}> = []
325-
326334
for (const part of candidate.content?.parts || []) {
327335
if (part.text) {
328336
textContent += part.text
329337
}
330-
if (part.functionCall) {
331-
tool_calls.push({
332-
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
333-
type: 'function',
334-
function: {
335-
name: part.functionCall.name,
336-
arguments: JSON.stringify(part.functionCall.args || {}),
337-
},
338-
})
339-
}
340338
}
341339

342340
// Parse grounding metadata
@@ -345,8 +343,7 @@ export class GoogleProvider implements LLMProviderInterface {
345343
return {
346344
content: textContent,
347345
groundingMetadata,
348-
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
349-
finish_reason: tool_calls.length > 0 ? 'tool_calls' : 'stop',
346+
finish_reason: 'stop',
350347
usage: data.usageMetadata
351348
? {
352349
promptTokens: data.usageMetadata.promptTokenCount || 0,
@@ -447,30 +444,16 @@ export class GoogleProvider implements LLMProviderInterface {
447444
const { contents, systemInstruction } =
448445
await this.convertMessagesToNativeFormat(messages)
449446

450-
// Build tools array: google_search + custom function declarations
451-
const toolsArray: Record<string, unknown>[] = [{ google_search: {} }]
452-
453-
// Include custom function tools alongside Google Search grounding
454-
if (config?.tools && config.tools.length > 0) {
455-
const functionDeclarations = config.tools
456-
.filter((t) => t.type === 'function' && t.function)
457-
.map((t) => ({
458-
name: t.function.name,
459-
description: t.function.description,
460-
parameters: t.function.parameters,
461-
}))
462-
if (functionDeclarations.length > 0) {
463-
toolsArray.push({ function_declarations: functionDeclarations })
464-
}
465-
}
466-
447+
// Only google_search for grounded requests.
448+
// Combining google_search with function_declarations is only supported
449+
// in the Live API — the regular streamGenerateContent endpoint rejects it.
467450
const requestBody: Record<string, unknown> = {
468451
contents,
469452
generationConfig: {
470453
temperature: config?.temperature || 0.7,
471454
maxOutputTokens: config?.maxTokens,
472455
},
473-
tools: toolsArray,
456+
tools: [{ google_search: {} }],
474457
}
475458

476459
if (systemInstruction) {
@@ -514,24 +497,12 @@ export class GoogleProvider implements LLMProviderInterface {
514497
const parsed = JSON.parse(data)
515498
const candidate = parsed.candidates?.[0]
516499

517-
// Extract text and function calls from parts
500+
// Extract text from parts (no function calls in grounding-only mode)
518501
if (candidate?.content?.parts) {
519502
for (const part of candidate.content.parts) {
520503
if (part.text) {
521504
yield part.text
522505
}
523-
if (part.functionCall) {
524-
// Emit function call as __TOOL_CALLS__ marker for the tool execution loop
525-
const toolCall = {
526-
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
527-
type: 'function',
528-
function: {
529-
name: part.functionCall.name,
530-
arguments: JSON.stringify(part.functionCall.args || {}),
531-
},
532-
}
533-
yield `\n__TOOL_CALLS__${JSON.stringify([toolCall])}`
534-
}
535506
}
536507
}
537508

src/test/lib/llm/providers/google.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
88
import { GoogleProvider } from '@/lib/llm/providers/google'
9+
import { sanitizeSchemaForGemini } from '@/lib/llm/providers/google'
910

1011
// Mock fetch globally
1112
const mockFetch = vi.fn()
@@ -158,4 +159,160 @@ describe('GoogleProvider', () => {
158159
expect(url).not.toContain('/models/google/')
159160
})
160161
})
162+
163+
describe('sanitizeSchemaForGemini', () => {
164+
it('should remove top-level additionalProperties', () => {
165+
const schema = {
166+
type: 'object',
167+
properties: {
168+
name: { type: 'string' },
169+
},
170+
required: ['name'],
171+
additionalProperties: false,
172+
}
173+
const result = sanitizeSchemaForGemini(schema)
174+
expect(result).toEqual({
175+
type: 'object',
176+
properties: {
177+
name: { type: 'string' },
178+
},
179+
required: ['name'],
180+
})
181+
expect(result).not.toHaveProperty('additionalProperties')
182+
})
183+
184+
it('should remove nested additionalProperties from property values', () => {
185+
const schema = {
186+
type: 'object',
187+
properties: {
188+
variables: {
189+
type: 'object',
190+
description: 'Named variables',
191+
additionalProperties: { type: 'number' },
192+
},
193+
},
194+
}
195+
const result = sanitizeSchemaForGemini(schema)
196+
expect(result).toEqual({
197+
type: 'object',
198+
properties: {
199+
variables: {
200+
type: 'object',
201+
description: 'Named variables',
202+
},
203+
},
204+
})
205+
})
206+
207+
it('should preserve non-additionalProperties fields', () => {
208+
const schema = {
209+
type: 'object',
210+
properties: {
211+
expression: {
212+
type: 'string',
213+
description: 'Math expression',
214+
},
215+
precision: {
216+
type: 'integer',
217+
minimum: 0,
218+
maximum: 20,
219+
},
220+
},
221+
required: ['expression'],
222+
}
223+
const result = sanitizeSchemaForGemini(schema)
224+
expect(result).toEqual(schema)
225+
})
226+
227+
it('should handle deeply nested schemas', () => {
228+
const schema = {
229+
type: 'object',
230+
properties: {
231+
outer: {
232+
type: 'object',
233+
properties: {
234+
inner: {
235+
type: 'object',
236+
additionalProperties: true,
237+
properties: {
238+
value: { type: 'string' },
239+
},
240+
},
241+
},
242+
additionalProperties: false,
243+
},
244+
},
245+
additionalProperties: false,
246+
}
247+
const result = sanitizeSchemaForGemini(schema)
248+
expect(result).toEqual({
249+
type: 'object',
250+
properties: {
251+
outer: {
252+
type: 'object',
253+
properties: {
254+
inner: {
255+
type: 'object',
256+
properties: {
257+
value: { type: 'string' },
258+
},
259+
},
260+
},
261+
},
262+
},
263+
})
264+
})
265+
266+
it('should handle arrays in schema (e.g. enum values)', () => {
267+
const schema = {
268+
type: 'object',
269+
properties: {
270+
mode: {
271+
type: 'string',
272+
enum: ['fast', 'slow'],
273+
},
274+
},
275+
}
276+
const result = sanitizeSchemaForGemini(schema)
277+
expect(result).toEqual(schema)
278+
})
279+
280+
it('should handle empty schema', () => {
281+
const result = sanitizeSchemaForGemini({})
282+
expect(result).toEqual({})
283+
})
284+
285+
it('should strip additionalProperties from items in arrays of objects', () => {
286+
const schema = {
287+
type: 'object',
288+
properties: {
289+
items: {
290+
type: 'array',
291+
items: {
292+
type: 'object',
293+
additionalProperties: false,
294+
properties: {
295+
id: { type: 'string' },
296+
},
297+
},
298+
},
299+
},
300+
}
301+
const result = sanitizeSchemaForGemini(schema)
302+
expect(result).toEqual({
303+
type: 'object',
304+
properties: {
305+
items: {
306+
type: 'array',
307+
items: {
308+
type: 'object',
309+
properties: {
310+
id: { type: 'string' },
311+
},
312+
},
313+
},
314+
},
315+
})
316+
})
317+
})
161318
})

0 commit comments

Comments
 (0)