diff --git a/core/src/common.ts b/core/src/common.ts index 23f628165..f905f642a 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -282,7 +282,11 @@ export {VertexRagRetrievalTool} from './tools/vertex_rag_retrieval_tool.js'; export {getClientLabels, runWithClientLabel} from './utils/client_labels.js'; export {LogLevel, getLogger, setLogLevel, setLogger} from './utils/logger.js'; export type {Logger} from './utils/logger.js'; -export {isGemini2OrAbove, isGemini3xFlashLive} from './utils/model_name.js'; +export { + isGemini2OrAbove, + isGemini35LiveTranslate, + isGemini3xLive, +} from './utils/model_name.js'; export {zodObjectToSchema} from './utils/simple_zod_to_json.js'; export {Task} from './utils/task.js'; export type {TaskExecutable} from './utils/task.js'; diff --git a/core/src/models/gemini_llm_connection.ts b/core/src/models/gemini_llm_connection.ts index 4b49740eb..4ca4adf6a 100644 --- a/core/src/models/gemini_llm_connection.ts +++ b/core/src/models/gemini_llm_connection.ts @@ -14,7 +14,7 @@ import { import {LiveResponseAggregator} from '../utils/live_connection_utils.js'; import {logger} from '../utils/logger.js'; -import {isGemini3xFlashLive} from '../utils/model_name.js'; +import {isGemini3xLive} from '../utils/model_name.js'; import {BaseLlmConnection} from './base_llm_connection.js'; import {LlmResponse} from './llm_response.js'; @@ -43,7 +43,7 @@ export class GeminiLlmConnection implements BaseLlmConnection { ); if (contents.length > 0) { - const isGemini3x = isGemini3xFlashLive(this.modelVersion); + const isGemini3x = isGemini3xLive(this.modelVersion); this.geminiSession.sendClientContent({ turns: contents, turnComplete: isGemini3x @@ -79,7 +79,7 @@ export class GeminiLlmConnection implements BaseLlmConnection { }); } else { logger.debug('Sending LLM new content', content); - const isGemini3x = isGemini3xFlashLive(this.modelVersion); + const isGemini3x = isGemini3xLive(this.modelVersion); if (isGemini3x && content.parts.length === 1 && content.parts[0].text) { logger.debug('Using sendRealtimeInput for Gemini 3.x text input'); this.geminiSession.sendRealtimeInput({text: content.parts[0].text}); @@ -99,7 +99,7 @@ export class GeminiLlmConnection implements BaseLlmConnection { */ async sendRealtime(blob: Blob): Promise { logger.debug('Sending LLM Blob:', blob); - const isGemini3x = isGemini3xFlashLive(this.modelVersion); + const isGemini3x = isGemini3xLive(this.modelVersion); const isNativeAudio = this.modelVersion?.includes('native-audio'); if (isGemini3x || isNativeAudio) { diff --git a/core/src/utils/live_connection_utils.ts b/core/src/utils/live_connection_utils.ts index 08d8b0687..64900f449 100644 --- a/core/src/utils/live_connection_utils.ts +++ b/core/src/utils/live_connection_utils.ts @@ -11,7 +11,7 @@ import { Part, } from '@google/genai'; import {LlmResponse} from '../models/llm_response.js'; -import {isGemini3xFlashLive} from './model_name.js'; +import {isGemini3xLive} from './model_name.js'; /** * Aggregator and mapper for Gemini Live WebSocket server messages. @@ -243,7 +243,7 @@ export class LiveResponseAggregator { ); } - const isGemini3x = isGemini3xFlashLive(this.modelVersion); + const isGemini3x = isGemini3xLive(this.modelVersion); if (isGemini3x && this.toolCallParts.length > 0) { yield { content: {role: 'model', parts: this.toolCallParts}, diff --git a/core/src/utils/model_name.ts b/core/src/utils/model_name.ts index 6851eaba4..ffbe6ac1c 100644 --- a/core/src/utils/model_name.ts +++ b/core/src/utils/model_name.ts @@ -95,17 +95,39 @@ export function isGemini2OrAbove(modelString: string): boolean { } /** - * Check if the model is a Gemini 3.x Flash Live model. + * Check if the model is a Gemini 3.x Live model. + * + * Live Translate models are excluded; use {@link isGemini35LiveTranslate} for + * those. * * @param modelString Either a simple model name or path-based model name - * @return true if it's a Gemini 3.x Flash Live model, false otherwise. + * @return true if it's a Gemini 3.x Live model, false otherwise. */ -export function isGemini3xFlashLive(modelString: string | undefined): boolean { +export function isGemini3xLive(modelString: string | undefined): boolean { if (!modelString) { return false; } const modelName = extractModelName(modelString); - return modelName.startsWith('gemini-3.') && modelName.includes('-flash-live'); + return ( + modelName.startsWith('gemini-3.') && + modelName.includes('-live') && + !isGemini35LiveTranslate(modelString) + ); +} + +/** + * Check if the model is a Gemini 3.5 Live Translate model. + * + * @param modelString Either a simple model name or path-based model name + * @return true if it's a Gemini 3.5 Live Translate model, false otherwise. + */ +export function isGemini35LiveTranslate( + modelString: string | undefined, +): boolean { + if (!modelString) { + return false; + } + return extractModelName(modelString).startsWith('gemini-3.5-live-translate'); } /** diff --git a/core/test/models/gemini_llm_connection_test.ts b/core/test/models/gemini_llm_connection_test.ts index 34ab7902d..b4309e222 100644 --- a/core/test/models/gemini_llm_connection_test.ts +++ b/core/test/models/gemini_llm_connection_test.ts @@ -67,6 +67,24 @@ describe('GeminiLlmConnection', () => { }); }); + it('should send history with turnComplete=true for non-flash Gemini 3.x Live', async () => { + const connection = new GeminiLlmConnection( + mockSession, + 'gemini-3.5-flash-lite-live-preview', + ); + const history: Content[] = [ + {role: 'user', parts: [{text: 'hello'}]}, + {role: 'model', parts: [{text: 'hi'}]}, + ]; + + await connection.sendHistory(history); + + expect(mockSession.sendClientContent).toHaveBeenCalledWith({ + turns: history, + turnComplete: true, + }); + }); + it('should not send history if empty', async () => { const connection = new GeminiLlmConnection( mockSession, @@ -118,6 +136,23 @@ describe('GeminiLlmConnection', () => { }); }); + it('should use sendRealtimeInput for non-flash Gemini 3.x Live single-part text', async () => { + const connection = new GeminiLlmConnection( + mockSession, + 'gemini-3.5-flash-lite-live-preview', + ); + const content: Content = { + parts: [{text: 'hello'}], + }; + + await connection.sendContent(content); + + expect(mockSession.sendRealtimeInput).toHaveBeenCalledWith({ + text: 'hello', + }); + expect(mockSession.sendClientContent).not.toHaveBeenCalled(); + }); + it('should use sendClientContent for non-Gemini 3.x single-part text', async () => { const connection = new GeminiLlmConnection( mockSession, @@ -189,6 +224,34 @@ describe('GeminiLlmConnection', () => { }); }); + it('should use sendRealtimeInput with audio for non-flash Gemini 3.x Live audio', async () => { + const connection = new GeminiLlmConnection( + mockSession, + 'gemini-3.5-flash-lite-live-preview', + ); + const blob: Blob = {mimeType: 'audio/pcm', data: 'base64data'}; + + await connection.sendRealtime(blob); + + expect(mockSession.sendRealtimeInput).toHaveBeenCalledWith({ + audio: blob, + }); + }); + + it('should use sendRealtimeInput with media for Live Translate audio', async () => { + const connection = new GeminiLlmConnection( + mockSession, + 'gemini-3.5-live-translate', + ); + const blob: Blob = {mimeType: 'audio/pcm', data: 'base64data'}; + + await connection.sendRealtime(blob); + + expect(mockSession.sendRealtimeInput).toHaveBeenCalledWith({ + media: blob, + }); + }); + it('should use sendRealtimeInput with audio for Native Audio model audio', async () => { const connection = new GeminiLlmConnection( mockSession, @@ -790,7 +853,7 @@ describe('GeminiLlmConnection', () => { expect((await generator.next()).done).toBe(true); }); - it('should handle undefined modelVersion in isGemini3xFlashLive check', async () => { + it('should handle undefined modelVersion in isGemini3xLive check', async () => { const connection = new GeminiLlmConnection( mockSession, undefined, diff --git a/core/test/utils/live_connection_utils_test.ts b/core/test/utils/live_connection_utils_test.ts index b4518b06e..4843fa7f3 100644 --- a/core/test/utils/live_connection_utils_test.ts +++ b/core/test/utils/live_connection_utils_test.ts @@ -316,6 +316,29 @@ describe('LiveResponseAggregator', () => { ]); }); + it('should yield tool calls immediately for non-flash Gemini 3.x Live', () => { + const aggregator = new LiveResponseAggregator( + 'gemini-3.5-flash-lite-live-preview', + ); + + const res1 = Array.from( + aggregator.processMessage({ + toolCall: { + functionCalls: [{name: 'tool_a', args: {x: 1}, id: '1'}], + }, + }), + ); + expect(res1).toEqual([ + { + content: { + role: 'model', + parts: [{functionCall: {name: 'tool_a', args: {x: 1}, id: '1'}}], + }, + modelVersion: 'gemini-3.5-flash-lite-live-preview', + }, + ]); + }); + it('should yield session resumption update', () => { const aggregator = new LiveResponseAggregator('gemini-2.5-flash'); const resumptionUpdate = {resumed: true}; diff --git a/core/test/utils/model_name_test.ts b/core/test/utils/model_name_test.ts index caabe4022..12929ae18 100644 --- a/core/test/utils/model_name_test.ts +++ b/core/test/utils/model_name_test.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {isGemini2OrAbove, isGemini3xFlashLive} from '@google/adk'; +import { + isGemini2OrAbove, + isGemini35LiveTranslate, + isGemini3xLive, +} from '@google/adk'; import {describe, expect, it} from 'vitest'; describe('isGemini2OrAbove', () => { @@ -55,28 +59,68 @@ describe('isGemini2OrAbove', () => { }); }); -describe('isGemini3xFlashLive', () => { +describe('isGemini3xLive', () => { it('should return true for valid Gemini 3.x Flash Live models', () => { - expect(isGemini3xFlashLive('gemini-3.1-flash-live')).toBe(true); - expect(isGemini3xFlashLive('gemini-3.1-flash-live-preview')).toBe(true); - expect(isGemini3xFlashLive('gemini-3.5-flash-live')).toBe(true); - expect(isGemini3xFlashLive('gemini-3.5-flash-live-preview')).toBe(true); + expect(isGemini3xLive('gemini-3.1-flash-live')).toBe(true); + expect(isGemini3xLive('gemini-3.1-flash-live-preview')).toBe(true); + expect(isGemini3xLive('gemini-3.5-flash-live')).toBe(true); + expect(isGemini3xLive('gemini-3.5-flash-live-preview')).toBe(true); expect( - isGemini3xFlashLive( + isGemini3xLive( 'projects/my-project/locations/us-central1/publishers/google/models/gemini-3.1-flash-live-001', ), ).toBe(true); expect( - isGemini3xFlashLive( + isGemini3xLive( 'projects/my-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-live-001', ), ).toBe(true); }); it('should return false for other models', () => { - expect(isGemini3xFlashLive('gemini-2.5-flash')).toBe(false); - expect(isGemini3xFlashLive('gemini-3.0-flash')).toBe(false); - expect(isGemini3xFlashLive(undefined)).toBe(false); - expect(isGemini3xFlashLive('')).toBe(false); + expect(isGemini3xLive('gemini-2.5-flash')).toBe(false); + expect(isGemini3xLive('gemini-3.0-flash')).toBe(false); + expect(isGemini3xLive(undefined)).toBe(false); + expect(isGemini3xLive('')).toBe(false); + }); + + it('should return true for non-flash Gemini 3.x Live models', () => { + expect(isGemini3xLive('gemini-3.5-flash-lite-live-preview')).toBe(true); + expect( + isGemini3xLive( + 'projects/my-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite-live-preview', + ), + ).toBe(true); + }); + + it('should return false for Live Translate models', () => { + expect(isGemini3xLive('gemini-3.5-live-translate')).toBe(false); + expect( + isGemini3xLive( + 'projects/my-project/locations/us-central1/publishers/google/models/gemini-3.5-live-translate-preview', + ), + ).toBe(false); + }); + + it('should return false for non-live and pre-3.x live models', () => { + expect(isGemini3xLive('gemini-3.1-pro')).toBe(false); + expect(isGemini3xLive('gemini-2.5-flash-live')).toBe(false); + }); +}); + +describe('isGemini35LiveTranslate', () => { + it('should return true for Live Translate models', () => { + expect(isGemini35LiveTranslate('gemini-3.5-live-translate')).toBe(true); + expect( + isGemini35LiveTranslate( + 'projects/my-project/locations/us-central1/publishers/google/models/gemini-3.5-live-translate-preview', + ), + ).toBe(true); + }); + + it('should return false for other models', () => { + expect(isGemini35LiveTranslate('gemini-3.5-flash-live')).toBe(false); + expect(isGemini35LiveTranslate(undefined)).toBe(false); + expect(isGemini35LiveTranslate('')).toBe(false); }); });