From d628f762a3d7e8a7f2c44f461d8c8dc2dc896d84 Mon Sep 17 00:00:00 2001 From: "rosetta-livekit-bot[bot]" <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:06:12 +0000 Subject: [PATCH 1/3] fix(google): gate mixed Gemini tools --- .changeset/gemini-mixed-tools-cache.md | 5 + plugins/google/src/llm.test.ts | 174 +++++++++++++++++++- plugins/google/src/llm.ts | 130 ++++++++------- plugins/google/src/realtime/realtime_api.ts | 11 +- plugins/google/src/utils.ts | 35 ++-- 5 files changed, 274 insertions(+), 81 deletions(-) create mode 100644 .changeset/gemini-mixed-tools-cache.md diff --git a/.changeset/gemini-mixed-tools-cache.md b/.changeset/gemini-mixed-tools-cache.md new file mode 100644 index 000000000..592213767 --- /dev/null +++ b/.changeset/gemini-mixed-tools-cache.md @@ -0,0 +1,5 @@ +--- +"@livekit/agents-plugin-google": patch +--- + +Gate mixed Gemini built-in and function tool requests to the Gemini 3 Developer API and build Google tool config in chat requests. diff --git a/plugins/google/src/llm.test.ts b/plugins/google/src/llm.test.ts index c9801b570..cf8ed0bea 100644 --- a/plugins/google/src/llm.test.ts +++ b/plugins/google/src/llm.test.ts @@ -1,15 +1,183 @@ // SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -import { llm } from '@livekit/agents-plugins-test'; -import { describe, it } from 'vitest'; +import type * as types from '@google/genai'; +import { FunctionCallingConfigMode } from '@google/genai'; +import { llm as agentsLlm } from '@livekit/agents'; +import { llm as testLlm } from '@livekit/agents-plugins-test'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LLM } from './llm.js'; +import { GoogleSearch } from './tools.js'; + +const { generateContentStreamMock } = vi.hoisted(() => ({ + generateContentStreamMock: vi.fn(), +})); + +vi.mock('@google/genai', async (importOriginal) => { + const actual = await importOriginal(); + return Object.assign({}, actual, { + GoogleGenAI: vi.fn(function GoogleGenAI() { + return { + models: { + generateContentStream: generateContentStreamMock, + }, + }; + }), + }); +}); const hasGoogleApiKey = Boolean(process.env.GOOGLE_API_KEY); +async function* singleResponseAsyncIter(): AsyncGenerator { + yield { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'ok' }] }, + finishReason: 'STOP', + }, + ], + } as types.GenerateContentResponse; +} + +async function captureConfig( + llm: LLM, + chatOptions: Omit[0], 'chatCtx'> = {}, +): Promise { + let capturedConfig: types.GenerateContentConfig | undefined; + generateContentStreamMock.mockImplementation( + async ({ config }: { config: types.GenerateContentConfig }) => { + capturedConfig = config; + return singleResponseAsyncIter(); + }, + ); + + const stream = llm.chat({ chatCtx: agentsLlm.ChatContext.empty(), ...chatOptions }); + await stream.collect(); + + expect(capturedConfig).toBeDefined(); + return capturedConfig!; +} + +function weatherTool() { + return agentsLlm.tool({ + name: 'get_weather', + description: 'Look up the weather.', + execute: async () => 'ok', + }); +} + +function hasGoogleSearch(config: types.GenerateContentConfig): boolean { + return Boolean(config.tools?.some((tool) => 'googleSearch' in tool)); +} + +function hasFunctionDeclarations(config: types.GenerateContentConfig): boolean { + return Boolean(config.tools?.some((tool) => 'functionDeclarations' in tool)); +} + +function serverSideEnabled(config: types.GenerateContentConfig): boolean { + return Boolean(config.toolConfig?.includeServerSideToolInvocations); +} + +describe('Google mixed tools request construction', () => { + beforeEach(() => { + generateContentStreamMock.mockReset(); + }); + + it('enables server-side invocations on the Gemini 3 Developer API', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + }); + + expect(serverSideEnabled(config)).toBe(true); + expect(hasFunctionDeclarations(config)).toBe(true); + expect(hasGoogleSearch(config)).toBe(true); + }); + + it('keeps auto mode for mixed tools', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + toolChoice: 'auto', + }); + + expect(serverSideEnabled(config)).toBe(true); + expect(config.toolConfig?.functionCallingConfig?.mode).toBe(FunctionCallingConfigMode.AUTO); + }); + + it('drops provider tools below Gemini 3', async () => { + const google = new LLM({ model: 'gemini-2.5-flash', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + }); + + expect(serverSideEnabled(config)).toBe(false); + expect(hasFunctionDeclarations(config)).toBe(true); + expect(hasGoogleSearch(config)).toBe(false); + }); + + it('drops provider tools for Vertex AI Gemini 3', async () => { + const google = new LLM({ + model: 'gemini-3-flash-preview', + vertexai: true, + project: 'test-project', + location: 'us-central1', + }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + }); + + expect(serverSideEnabled(config)).toBe(false); + expect(hasFunctionDeclarations(config)).toBe(true); + expect(hasGoogleSearch(config)).toBe(false); + }); + + it('does not set the flag for provider tools alone', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { toolCtx: [new GoogleSearch()] }); + + expect(serverSideEnabled(config)).toBe(false); + expect(hasGoogleSearch(config)).toBe(true); + }); + + it('suppresses tools for cachedContent from extraKwargs', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + extraKwargs: { cachedContent: 'cachedContents/abc123' }, + }); + + expect(config.cachedContent).toBe('cachedContents/abc123'); + expect(config.tools).toBeUndefined(); + expect(config.toolConfig).toBeUndefined(); + }); + + it('strips raw tools from extraKwargs when cachedContent is active', async () => { + const google = new LLM({ + model: 'gemini-3-flash-preview', + apiKey: 'test', + cachedContent: 'cachedContents/abc', + }); + const config = await captureConfig(google, { + extraKwargs: { + tools: [{ googleSearch: {} }], + toolConfig: { + functionCallingConfig: { + mode: FunctionCallingConfigMode.AUTO, + }, + }, + }, + }); + + expect(config.cachedContent).toBe('cachedContents/abc'); + expect(config.tools).toBeUndefined(); + expect(config.toolConfig).toBeUndefined(); + }); +}); + if (hasGoogleApiKey) { describe('Google', async () => { - await llm( + await testLlm( new LLM({ model: 'gemini-2.5-flash', temperature: 0, diff --git a/plugins/google/src/llm.ts b/plugins/google/src/llm.ts index 7bc67a5bc..1036336e2 100644 --- a/plugins/google/src/llm.ts +++ b/plugins/google/src/llm.ts @@ -30,6 +30,37 @@ function isGemini3Model(model: string): boolean { return modelLower.includes('gemini-3'); } +function toFunctionCallingConfig( + toolChoice: llm.ToolChoice | undefined, + toolCtx: llm.ToolContext | undefined, +): types.FunctionCallingConfig | undefined { + if (toolChoice === undefined) { + return undefined; + } + + if (typeof toolChoice === 'object' && toolChoice.type === 'function') { + return { + mode: FunctionCallingConfigMode.ANY, + allowedFunctionNames: [toolChoice.function.name], + }; + } + if (toolChoice === 'required') { + const toolNames = llm.sortedToolNames(toolCtx); + return { + mode: FunctionCallingConfigMode.ANY, + allowedFunctionNames: toolNames.length > 0 ? toolNames : undefined, + }; + } + if (toolChoice === 'auto') { + return { mode: FunctionCallingConfigMode.AUTO }; + } + if (toolChoice === 'none') { + return { mode: FunctionCallingConfigMode.NONE }; + } + + throw new Error(`Invalid tool choice: ${toolChoice}`); +} + function isGemini3FlashModel(model: string): boolean { const modelLower = model.toLowerCase(); return modelLower.includes('gemini-3') && modelLower.includes('flash'); @@ -231,42 +262,44 @@ export class LLM extends llm.LLM { } toolChoice = toolChoice !== undefined ? toolChoice : this.#opts.toolChoice; + geminiTools = geminiTools !== undefined ? geminiTools : this.#opts.geminiTools; - if (toolChoice) { - let geminiToolConfig: types.ToolConfig; - - if (typeof toolChoice === 'object' && toolChoice.type === 'function') { - geminiToolConfig = { - functionCallingConfig: { - mode: FunctionCallingConfigMode.ANY, - allowedFunctionNames: [toolChoice.function.name], - }, - }; - } else if (toolChoice === 'required') { - const toolNames = llm.sortedToolNames(toolCtx); - geminiToolConfig = { - functionCallingConfig: { - mode: FunctionCallingConfigMode.ANY, - allowedFunctionNames: toolNames.length > 0 ? toolNames : undefined, - }, - }; - } else if (toolChoice === 'auto') { - geminiToolConfig = { - functionCallingConfig: { - mode: FunctionCallingConfigMode.AUTO, - }, - }; - } else if (toolChoice === 'none') { - geminiToolConfig = { - functionCallingConfig: { - mode: FunctionCallingConfigMode.NONE, - }, + // Mixing built-in (provider) tools with function tools is only supported on the Gemini 3 + // Developer API, not Vertex AI. https://ai.google.dev/gemini-api/docs/tool-combination + const allowMixedTools = isGemini3Model(this.#opts.model) && !this.#opts.vertexai; + const usingCache = this.#opts.cachedContent !== undefined || 'cachedContent' in extras; + + if (usingCache) { + const dropped = ['tools', 'toolConfig'].filter((key) => key in extras); + delete extras.tools; + delete extras.toolConfig; + + const hasTools = + (toolCtx !== undefined && + (Object.keys(toolCtx.functionTools).length > 0 || toolCtx.providerTools.length > 0)) || + geminiTools !== undefined || + dropped.length > 0; + if (hasTools) { + log().warn( + { model: this.#opts.model }, + 'gemini llm: ignoring tools; bake them into the CachedContent resource', + ); + } + } else { + const [toolsConfig, mixed] = toToolsConfig({ toolCtx, geminiTools, allowMixedTools }); + const functionCallingConfig = toFunctionCallingConfig(toolChoice, toolCtx); + + if (functionCallingConfig !== undefined || mixed) { + extras.toolConfig = { + ...(extras.toolConfig ?? {}), + functionCallingConfig, + includeServerSideToolInvocations: mixed || undefined, }; - } else { - throw new Error(`Invalid tool choice: ${toolChoice}`); } - extras.toolConfig = geminiToolConfig; + if (toolsConfig !== undefined) { + extras.tools = toolsConfig; + } } if (this.#opts.temperature !== undefined) { @@ -342,15 +375,12 @@ export class LLM extends llm.LLM { extras.mediaResolution = this.#opts.mediaResolution; } - geminiTools = geminiTools !== undefined ? geminiTools : this.#opts.geminiTools; - return new LLMStream(this, { client: this.#client, model: this.#opts.model, chatCtx, toolCtx, connOptions, - geminiTools, extraKwargs: extras, }); } @@ -368,7 +398,6 @@ const BLOCKED_REASONS = [ export class LLMStream extends llm.LLMStream { #client: GoogleGenAI; #model: string; - #geminiTools?: LLMTools; #extraKwargs: GenerateContentConfig; constructor( @@ -379,7 +408,6 @@ export class LLMStream extends llm.LLMStream { chatCtx, toolCtx, connOptions, - geminiTools, extraKwargs, }: { client: GoogleGenAI; @@ -387,7 +415,6 @@ export class LLMStream extends llm.LLMStream { chatCtx: llm.ChatContext; toolCtx?: llm.ToolContext; connOptions: APIConnectOptions; - geminiTools?: LLMTools; extraKwargs: GenerateContentConfig; }, ) { @@ -395,7 +422,6 @@ export class LLMStream extends llm.LLMStream { super(llm, { chatCtx, toolCtx, connOptions }); this.#client = client; this.#model = model; - this.#geminiTools = geminiTools; this.#extraKwargs = extraKwargs; } @@ -414,12 +440,6 @@ export class LLMStream extends llm.LLMStream { parts: turn.parts as types.Part[], })); - const tools = toToolsConfig({ - toolCtx: this.toolCtx, - geminiTools: this.#geminiTools, - onlySingleType: true, - }); - let systemInstruction: types.Content | undefined = undefined; if (extraData.systemMessages && extraData.systemMessages.length > 0) { systemInstruction = { @@ -428,23 +448,18 @@ export class LLMStream extends llm.LLMStream { } // Gemini's API rejects `generateContent` requests that pass `cachedContent` together with - // `systemInstruction`, `tools`, or `toolConfig` — those fields must live INSIDE the - // CachedContent resource, not on the request. The application bakes them into the cache via - // `client.caches.create(...)`; here we just suppress the duplicates on the outgoing request - // whenever a cache is attached. + // `systemInstruction`; it must live inside the CachedContent resource. const cachedContent = this.#extraKwargs.cachedContent; const usingCache = cachedContent !== undefined; const requestConfig: GenerateContentConfig = { ...this.#extraKwargs }; if (!usingCache) { requestConfig.systemInstruction = systemInstruction; - requestConfig.tools = tools; } else { - const dropped = ['tools', 'toolConfig', 'systemInstruction'].filter( - (key) => key in requestConfig, - ); - if (tools && !dropped.includes('tools')) { - dropped.push('tools'); + const dropped: string[] = []; + if ('systemInstruction' in requestConfig) { + delete requestConfig.systemInstruction; + dropped.push('systemInstruction'); } if (systemInstruction && !dropped.includes('systemInstruction')) { dropped.push('systemInstruction'); @@ -452,12 +467,9 @@ export class LLMStream extends llm.LLMStream { if (dropped.length > 0) { this.logger.warn( { dropped, cachedContent }, - 'dropping fields from Gemini request because cachedContent is set; these fields must be baked into the CachedContent resource', + 'dropping systemInstruction from Gemini request because cachedContent is set; this field must be baked into the CachedContent resource', ); } - delete requestConfig.tools; - delete requestConfig.toolConfig; - delete requestConfig.systemInstruction; } const httpOptions = { diff --git a/plugins/google/src/realtime/realtime_api.ts b/plugins/google/src/realtime/realtime_api.ts index d12ca3b92..214cd0039 100644 --- a/plugins/google/src/realtime/realtime_api.ts +++ b/plugins/google/src/realtime/realtime_api.ts @@ -1424,6 +1424,11 @@ export class RealtimeSession extends llm.RealtimeSession { private buildConnectConfig(): types.LiveConnectConfig { const opts = this.options; + const [tools] = toToolsConfig({ + toolCtx: this._tools, + geminiTools: this.options.geminiTools, + toolBehavior: this.options.toolBehavior, + }); const config: types.LiveConnectConfig = { thinkingConfig: opts.thinkingConfig, @@ -1441,11 +1446,7 @@ export class RealtimeSession extends llm.RealtimeSession { }, languageCode: opts.language, }, - tools: toToolsConfig({ - toolCtx: this._tools, - geminiTools: this.options.geminiTools, - toolBehavior: this.options.toolBehavior, - }), + tools, inputAudioTranscription: opts.inputAudioTranscription, outputAudioTranscription: opts.outputAudioTranscription, sessionResumption: this.sessionResumptionHandle diff --git a/plugins/google/src/utils.ts b/plugins/google/src/utils.ts index f6a2d7eca..5a8031498 100644 --- a/plugins/google/src/utils.ts +++ b/plugins/google/src/utils.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import type * as types from '@google/genai'; import type { FunctionDeclaration, Schema } from '@google/genai'; -import { llm } from '@livekit/agents'; +import { llm, log } from '@livekit/agents'; import type { JSONSchema7 } from 'json-schema'; import { GeminiTool, type LLMTools } from './tools.js'; @@ -164,18 +164,20 @@ export function toToolsConfig({ toolCtx, geminiTools, toolBehavior, - onlySingleType = false, + allowMixedTools = true, }: { toolCtx?: llm.ToolContext; geminiTools?: LLMTools; toolBehavior?: types.Behavior; - onlySingleType?: boolean; -}): types.Tool[] | undefined { + allowMixedTools?: boolean; +}): [types.Tool[] | undefined, boolean] { const tools: types.Tool[] = []; + let hasFunctionTools = false; if (toolCtx) { const functionDeclarations = toFunctionDeclarations(toolCtx); if (functionDeclarations.length > 0) { + hasFunctionTools = true; tools.push({ functionDeclarations: toolBehavior !== undefined @@ -188,23 +190,28 @@ export function toToolsConfig({ } } - // Some Google LLMs do not support multiple tool types (either function tools or builtin tools). - // Short-circuit before adding provider tools, matching Python `create_tools_config`. - if (onlySingleType && tools.length > 0) { - return tools; - } - + const providerTools: types.Tool[] = []; if (geminiTools !== undefined) { - tools.push(geminiTools); + providerTools.push(geminiTools); } - if (toolCtx) { + if (toolCtx !== undefined) { for (const tool of toolCtx.providerTools) { if (tool instanceof GeminiTool) { - tools.push(tool.toToolConfig()); + providerTools.push(tool.toToolConfig()); } } } - return tools.length > 0 ? tools : undefined; + // generateContent only supports combining built-in tools with function tools on the + // Gemini 3 Developer API: https://ai.google.dev/gemini-api/docs/tool-combination + if (hasFunctionTools && providerTools.length > 0 && !allowMixedTools) { + log().warn( + 'ignoring provider tools; combining them with function tools requires the Gemini 3 Developer API (Vertex AI is not supported)', + ); + return [tools.length > 0 ? tools : undefined, false]; + } + + tools.push(...providerTools); + return [tools.length > 0 ? tools : undefined, hasFunctionTools && providerTools.length > 0]; } From b79ec082187a03190afc9bb8b7768bc54f9ab799 Mon Sep 17 00:00:00 2001 From: Toubat Date: Tue, 14 Jul 2026 16:42:49 -0700 Subject: [PATCH 2/3] test(google): isolate request construction mocks Keep mocked provider-boundary coverage from replacing credentialed integration clients, and cover tool-choice mapping plus retry-stable configs. Co-authored-by: Cursor --- plugins/google/src/llm.test.ts | 174 +---------------- plugins/google/src/llm_request.test.ts | 255 +++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 171 deletions(-) create mode 100644 plugins/google/src/llm_request.test.ts diff --git a/plugins/google/src/llm.test.ts b/plugins/google/src/llm.test.ts index cf8ed0bea..c9801b570 100644 --- a/plugins/google/src/llm.test.ts +++ b/plugins/google/src/llm.test.ts @@ -1,183 +1,15 @@ // SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -import type * as types from '@google/genai'; -import { FunctionCallingConfigMode } from '@google/genai'; -import { llm as agentsLlm } from '@livekit/agents'; -import { llm as testLlm } from '@livekit/agents-plugins-test'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { llm } from '@livekit/agents-plugins-test'; +import { describe, it } from 'vitest'; import { LLM } from './llm.js'; -import { GoogleSearch } from './tools.js'; - -const { generateContentStreamMock } = vi.hoisted(() => ({ - generateContentStreamMock: vi.fn(), -})); - -vi.mock('@google/genai', async (importOriginal) => { - const actual = await importOriginal(); - return Object.assign({}, actual, { - GoogleGenAI: vi.fn(function GoogleGenAI() { - return { - models: { - generateContentStream: generateContentStreamMock, - }, - }; - }), - }); -}); const hasGoogleApiKey = Boolean(process.env.GOOGLE_API_KEY); -async function* singleResponseAsyncIter(): AsyncGenerator { - yield { - candidates: [ - { - content: { role: 'model', parts: [{ text: 'ok' }] }, - finishReason: 'STOP', - }, - ], - } as types.GenerateContentResponse; -} - -async function captureConfig( - llm: LLM, - chatOptions: Omit[0], 'chatCtx'> = {}, -): Promise { - let capturedConfig: types.GenerateContentConfig | undefined; - generateContentStreamMock.mockImplementation( - async ({ config }: { config: types.GenerateContentConfig }) => { - capturedConfig = config; - return singleResponseAsyncIter(); - }, - ); - - const stream = llm.chat({ chatCtx: agentsLlm.ChatContext.empty(), ...chatOptions }); - await stream.collect(); - - expect(capturedConfig).toBeDefined(); - return capturedConfig!; -} - -function weatherTool() { - return agentsLlm.tool({ - name: 'get_weather', - description: 'Look up the weather.', - execute: async () => 'ok', - }); -} - -function hasGoogleSearch(config: types.GenerateContentConfig): boolean { - return Boolean(config.tools?.some((tool) => 'googleSearch' in tool)); -} - -function hasFunctionDeclarations(config: types.GenerateContentConfig): boolean { - return Boolean(config.tools?.some((tool) => 'functionDeclarations' in tool)); -} - -function serverSideEnabled(config: types.GenerateContentConfig): boolean { - return Boolean(config.toolConfig?.includeServerSideToolInvocations); -} - -describe('Google mixed tools request construction', () => { - beforeEach(() => { - generateContentStreamMock.mockReset(); - }); - - it('enables server-side invocations on the Gemini 3 Developer API', async () => { - const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); - const config = await captureConfig(google, { - toolCtx: [weatherTool(), new GoogleSearch()], - }); - - expect(serverSideEnabled(config)).toBe(true); - expect(hasFunctionDeclarations(config)).toBe(true); - expect(hasGoogleSearch(config)).toBe(true); - }); - - it('keeps auto mode for mixed tools', async () => { - const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); - const config = await captureConfig(google, { - toolCtx: [weatherTool(), new GoogleSearch()], - toolChoice: 'auto', - }); - - expect(serverSideEnabled(config)).toBe(true); - expect(config.toolConfig?.functionCallingConfig?.mode).toBe(FunctionCallingConfigMode.AUTO); - }); - - it('drops provider tools below Gemini 3', async () => { - const google = new LLM({ model: 'gemini-2.5-flash', apiKey: 'test' }); - const config = await captureConfig(google, { - toolCtx: [weatherTool(), new GoogleSearch()], - }); - - expect(serverSideEnabled(config)).toBe(false); - expect(hasFunctionDeclarations(config)).toBe(true); - expect(hasGoogleSearch(config)).toBe(false); - }); - - it('drops provider tools for Vertex AI Gemini 3', async () => { - const google = new LLM({ - model: 'gemini-3-flash-preview', - vertexai: true, - project: 'test-project', - location: 'us-central1', - }); - const config = await captureConfig(google, { - toolCtx: [weatherTool(), new GoogleSearch()], - }); - - expect(serverSideEnabled(config)).toBe(false); - expect(hasFunctionDeclarations(config)).toBe(true); - expect(hasGoogleSearch(config)).toBe(false); - }); - - it('does not set the flag for provider tools alone', async () => { - const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); - const config = await captureConfig(google, { toolCtx: [new GoogleSearch()] }); - - expect(serverSideEnabled(config)).toBe(false); - expect(hasGoogleSearch(config)).toBe(true); - }); - - it('suppresses tools for cachedContent from extraKwargs', async () => { - const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); - const config = await captureConfig(google, { - toolCtx: [weatherTool(), new GoogleSearch()], - extraKwargs: { cachedContent: 'cachedContents/abc123' }, - }); - - expect(config.cachedContent).toBe('cachedContents/abc123'); - expect(config.tools).toBeUndefined(); - expect(config.toolConfig).toBeUndefined(); - }); - - it('strips raw tools from extraKwargs when cachedContent is active', async () => { - const google = new LLM({ - model: 'gemini-3-flash-preview', - apiKey: 'test', - cachedContent: 'cachedContents/abc', - }); - const config = await captureConfig(google, { - extraKwargs: { - tools: [{ googleSearch: {} }], - toolConfig: { - functionCallingConfig: { - mode: FunctionCallingConfigMode.AUTO, - }, - }, - }, - }); - - expect(config.cachedContent).toBe('cachedContents/abc'); - expect(config.tools).toBeUndefined(); - expect(config.toolConfig).toBeUndefined(); - }); -}); - if (hasGoogleApiKey) { describe('Google', async () => { - await testLlm( + await llm( new LLM({ model: 'gemini-2.5-flash', temperature: 0, diff --git a/plugins/google/src/llm_request.test.ts b/plugins/google/src/llm_request.test.ts new file mode 100644 index 000000000..1f88f3322 --- /dev/null +++ b/plugins/google/src/llm_request.test.ts @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import type * as types from '@google/genai'; +import { FinishReason, FunctionCallingConfigMode, GenerateContentResponse } from '@google/genai'; +import { llm } from '@livekit/agents'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LLM } from './llm.js'; +import { GoogleSearch } from './tools.js'; + +const { generateContentStreamMock } = vi.hoisted(() => ({ + generateContentStreamMock: vi.fn(), +})); + +vi.mock('@google/genai', async (importOriginal) => { + const actual = await importOriginal(); + return Object.assign({}, actual, { + GoogleGenAI: vi.fn(function GoogleGenAI() { + return { + models: { + generateContentStream: generateContentStreamMock, + }, + }; + }), + }); +}); + +async function* singleResponseAsyncIter(): AsyncGenerator { + const response = new GenerateContentResponse(); + response.candidates = [ + { + content: { role: 'model', parts: [{ text: 'ok' }] }, + finishReason: FinishReason.STOP, + }, + ]; + yield response; +} + +async function captureConfig( + google: LLM, + chatOptions: Omit[0], 'chatCtx'> = {}, +): Promise { + let capturedConfig: types.GenerateContentConfig | undefined; + generateContentStreamMock.mockImplementation( + async ({ config }: { config: types.GenerateContentConfig }) => { + capturedConfig = config; + return singleResponseAsyncIter(); + }, + ); + + const stream = google.chat({ chatCtx: llm.ChatContext.empty(), ...chatOptions }); + await stream.collect(); + + if (capturedConfig === undefined) { + throw new Error('Google request config was not captured'); + } + return capturedConfig; +} + +function weatherTool() { + return llm.tool({ + name: 'get_weather', + description: 'Look up the weather.', + execute: async () => 'ok', + }); +} + +function temperatureTool() { + return llm.tool({ + name: 'get_temperature', + description: 'Look up the temperature.', + execute: async () => 'ok', + }); +} + +function hasGoogleSearch(config: types.GenerateContentConfig): boolean { + return Boolean(config.tools?.some((tool) => 'googleSearch' in tool)); +} + +function hasFunctionDeclarations(config: types.GenerateContentConfig): boolean { + return Boolean(config.tools?.some((tool) => 'functionDeclarations' in tool)); +} + +function serverSideEnabled(config: types.GenerateContentConfig): boolean { + return Boolean(config.toolConfig?.includeServerSideToolInvocations); +} + +describe('Google mixed tools request construction', () => { + beforeEach(() => { + generateContentStreamMock.mockReset(); + }); + + it('enables server-side invocations on the Gemini 3 Developer API', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + }); + + expect(serverSideEnabled(config)).toBe(true); + expect(hasFunctionDeclarations(config)).toBe(true); + expect(hasGoogleSearch(config)).toBe(true); + }); + + it('keeps auto mode for mixed tools', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + toolChoice: 'auto', + }); + + expect(serverSideEnabled(config)).toBe(true); + expect(config.toolConfig?.functionCallingConfig?.mode).toBe(FunctionCallingConfigMode.AUTO); + }); + + it.each([ + { + toolChoice: 'required' as const, + expectedMode: FunctionCallingConfigMode.ANY, + expectedNames: ['get_temperature', 'get_weather'], + }, + { + toolChoice: 'none' as const, + expectedMode: FunctionCallingConfigMode.NONE, + expectedNames: undefined, + }, + ])('maps $toolChoice tool choice', async ({ toolChoice, expectedMode, expectedNames }) => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), temperatureTool()], + toolChoice, + }); + + expect(config.toolConfig?.functionCallingConfig?.mode).toBe(expectedMode); + expect(config.toolConfig?.functionCallingConfig?.allowedFunctionNames).toEqual(expectedNames); + }); + + it('maps a named function tool choice', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), temperatureTool()], + toolChoice: { + type: 'function', + function: { name: 'get_weather' }, + }, + }); + + expect(config.toolConfig?.functionCallingConfig).toEqual({ + mode: FunctionCallingConfigMode.ANY, + allowedFunctionNames: ['get_weather'], + }); + }); + + it('drops provider tools below Gemini 3', async () => { + const google = new LLM({ model: 'gemini-2.5-flash', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + }); + + expect(serverSideEnabled(config)).toBe(false); + expect(hasFunctionDeclarations(config)).toBe(true); + expect(hasGoogleSearch(config)).toBe(false); + }); + + it('drops provider tools for Vertex AI Gemini 3', async () => { + const google = new LLM({ + model: 'gemini-3-flash-preview', + vertexai: true, + project: 'test-project', + location: 'us-central1', + }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + }); + + expect(serverSideEnabled(config)).toBe(false); + expect(hasFunctionDeclarations(config)).toBe(true); + expect(hasGoogleSearch(config)).toBe(false); + }); + + it('does not set the flag for provider tools alone', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { toolCtx: [new GoogleSearch()] }); + + expect(serverSideEnabled(config)).toBe(false); + expect(hasGoogleSearch(config)).toBe(true); + }); + + it('suppresses tools for cachedContent from extraKwargs', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + extraKwargs: { cachedContent: 'cachedContents/abc123' }, + }); + + expect(config.cachedContent).toBe('cachedContents/abc123'); + expect(config.tools).toBeUndefined(); + expect(config.toolConfig).toBeUndefined(); + }); + + it('strips raw tools from extraKwargs when cachedContent is active', async () => { + const google = new LLM({ + model: 'gemini-3-flash-preview', + apiKey: 'test', + cachedContent: 'cachedContents/abc', + }); + const config = await captureConfig(google, { + extraKwargs: { + tools: [{ googleSearch: {} }], + toolConfig: { + functionCallingConfig: { + mode: FunctionCallingConfigMode.AUTO, + }, + }, + }, + }); + + expect(config.cachedContent).toBe('cachedContents/abc'); + expect(config.tools).toBeUndefined(); + expect(config.toolConfig).toBeUndefined(); + }); + + it('constructs identical request config on retry', async () => { + const configs: types.GenerateContentConfig[] = []; + generateContentStreamMock + .mockImplementationOnce(async ({ config }: { config: types.GenerateContentConfig }) => { + configs.push(structuredClone(config)); + throw { code: 500, message: 'retry me' }; + }) + .mockImplementationOnce(async ({ config }: { config: types.GenerateContentConfig }) => { + configs.push(structuredClone(config)); + return singleResponseAsyncIter(); + }); + + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + google.on('error', () => {}); + const stream = google.chat({ + chatCtx: llm.ChatContext.empty(), + toolCtx: [weatherTool(), new GoogleSearch()], + toolChoice: 'auto', + connOptions: { maxRetry: 1, retryIntervalMs: 0, timeoutMs: 1000 }, + }); + await stream.collect(); + + expect(configs).toHaveLength(2); + expect(configs[1]).toEqual(configs[0]); + + const retryConfig = configs[1]; + if (retryConfig === undefined) { + throw new Error('Retry request config was not captured'); + } + expect(serverSideEnabled(retryConfig)).toBe(true); + expect(hasFunctionDeclarations(retryConfig)).toBe(true); + expect(hasGoogleSearch(retryConfig)).toBe(true); + }); +}); From 22e9167993341c18ff2e03ae4c480a80868b1dbd Mon Sep 17 00:00:00 2001 From: Toubat Date: Tue, 14 Jul 2026 16:49:44 -0700 Subject: [PATCH 3/3] fix(google): preserve custom function calling config Avoid replacing caller-supplied function-calling settings when mixed tools only require the server-side invocation flag. Co-authored-by: Cursor --- plugins/google/src/llm.ts | 2 +- plugins/google/src/llm_request.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/plugins/google/src/llm.ts b/plugins/google/src/llm.ts index 1036336e2..3af69274b 100644 --- a/plugins/google/src/llm.ts +++ b/plugins/google/src/llm.ts @@ -292,7 +292,7 @@ export class LLM extends llm.LLM { if (functionCallingConfig !== undefined || mixed) { extras.toolConfig = { ...(extras.toolConfig ?? {}), - functionCallingConfig, + ...(functionCallingConfig !== undefined ? { functionCallingConfig } : {}), includeServerSideToolInvocations: mixed || undefined, }; } diff --git a/plugins/google/src/llm_request.test.ts b/plugins/google/src/llm_request.test.ts index 1f88f3322..53ec40b4c 100644 --- a/plugins/google/src/llm_request.test.ts +++ b/plugins/google/src/llm_request.test.ts @@ -112,6 +112,23 @@ describe('Google mixed tools request construction', () => { expect(config.toolConfig?.functionCallingConfig?.mode).toBe(FunctionCallingConfigMode.AUTO); }); + it('preserves extraKwargs function calling config for mixed tools', async () => { + const google = new LLM({ model: 'gemini-3-flash-preview', apiKey: 'test' }); + const functionCallingConfig = { + mode: FunctionCallingConfigMode.ANY, + allowedFunctionNames: ['get_weather'], + }; + const config = await captureConfig(google, { + toolCtx: [weatherTool(), new GoogleSearch()], + extraKwargs: { + toolConfig: { functionCallingConfig }, + }, + }); + + expect(config.toolConfig?.functionCallingConfig).toEqual(functionCallingConfig); + expect(serverSideEnabled(config)).toBe(true); + }); + it.each([ { toolChoice: 'required' as const,