@@ -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+
4069export 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
0 commit comments