diff --git a/core/src/code_executors/built_in_code_executor.ts b/core/src/code_executors/built_in_code_executor.ts index b8a7ee014..4b3a0ebe8 100644 --- a/core/src/code_executors/built_in_code_executor.ts +++ b/core/src/code_executors/built_in_code_executor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import {LlmRequest} from '../models/llm_request.js'; -import {isGemini2OrAbove} from '../utils/model_name.js'; +import {isGeminiEapOr2OrAbove} from '../utils/model_name.js'; import {BaseCodeExecutor, ExecuteCodeParams} from './base_code_executor.js'; import {CodeExecutionResult} from './code_execution_utils.js'; @@ -36,8 +36,8 @@ export function isBuiltInCodeExecutor( /** * A code executor that uses the Model's built-in code executor. * - * Currently only supports Gemini 2.0+ models, but will be expanded to - * other models. + * Currently only supports Gemini 2.0+ and Gemini EAP models, but will be + * expanded to other models. */ export class BuiltInCodeExecutor extends BaseCodeExecutor { /** A unique symbol to identify BuiltInCodeExecutor class. */ @@ -52,7 +52,7 @@ export class BuiltInCodeExecutor extends BaseCodeExecutor { } processLlmRequest(llmRequest: LlmRequest) { - if (llmRequest.model && isGemini2OrAbove(llmRequest.model)) { + if (llmRequest.model && isGeminiEapOr2OrAbove(llmRequest.model)) { llmRequest.config = llmRequest.config || {}; llmRequest.config.tools = llmRequest.config.tools || []; llmRequest.config.tools.push({codeExecution: {}}); diff --git a/core/src/tools/url_context_tool.ts b/core/src/tools/url_context_tool.ts index 0388acfc6..b80a120b2 100644 --- a/core/src/tools/url_context_tool.ts +++ b/core/src/tools/url_context_tool.ts @@ -5,13 +5,13 @@ */ import {GenerateContentConfig} from '@google/genai'; -import {isGemini2OrAbove, isGeminiModel} from '../utils/model_name.js'; +import {isGeminiEapOr2OrAbove, isGeminiModel} from '../utils/model_name.js'; import {BaseTool, ToolProcessLlmRequest} from './base_tool.js'; /** - * A built-in tool that allows Gemini 2+ models to retrieve content from URLs - * provided in the conversation. + * A built-in tool that allows Gemini 2+ and Gemini EAP models to retrieve + * content from URLs provided in the conversation. * * This tool operates internally within the model and does not require or * perform local code execution. @@ -40,7 +40,7 @@ export class UrlContextTool extends BaseTool { ); } - if (!isGemini2OrAbove(llmRequest.model)) { + if (!isGeminiEapOr2OrAbove(llmRequest.model)) { throw new Error( `URL context tool requires Gemini 2 or above, but got ${llmRequest.model}`, ); diff --git a/core/src/utils/model_name.ts b/core/src/utils/model_name.ts index a1d653294..6a99e9ad0 100644 --- a/core/src/utils/model_name.ts +++ b/core/src/utils/model_name.ts @@ -8,23 +8,27 @@ import {getBooleanEnvVar} from './env_aware_utils.js'; /** * Path-based model name patterns, tried in order: the Vertex AI publisher path - * and the Apigee path (`apigee/[/][/]`). Declared - * without the `g` flag so `.match()` stays stateless. + * and the Apigee path (`apigee/[/][/]`). */ const MODEL_PATH_PATTERNS = [ /^projects\/[^/]+\/locations\/[^/]+\/publishers\/[^/]+\/models\/(.+)$/, /^apigee\/(?:[^/]+\/)?(?:[^/]+\/)?(.+)$/, ]; -const MODELS_PREFIX = 'models/'; - /** * Matches the Early Access Program (EAP) Gemini naming convention. Lower-case - * only, and without the `g` flag so `.test()` stays stateless. + * only. */ const EAP_MODEL_NAME_PATTERN = /^gemini-[a-z0-9_]+(?:-[a-z0-9_]+)*-early-exp\d*$/; +/** + * Matches Gemini 1.x names such as `gemini-1.5-pro`. The dotted minor version + * is mandatory, so a future double-digit major like `gemini-10.0-pro` is not + * mistaken for Gemini 1.x. + */ +const GEMINI_1_MODEL_NAME_PATTERN = /^gemini-1\.\d+/; + /** * Extract the actual model name from a simple, path-based, `models/`-prefixed * or provider-prefixed model string. @@ -51,8 +55,8 @@ export function extractModelName(modelString: string): string { } } - if (modelString.startsWith(MODELS_PREFIX)) { - return modelString.slice(MODELS_PREFIX.length); + if (modelString.startsWith('models/')) { + return modelString.slice('models/'.length); } // A 'projects/' string reaching here is a malformed Vertex path. Return it @@ -112,23 +116,17 @@ function parseVersion(versionString: string): ParsedVersion { * @return true if it's a Gemini 1.x model, false otherwise. */ export function isGemini1Model(modelString: string): boolean { - const modelName = extractModelName(modelString); - - return modelName.startsWith('gemini-1'); + return GEMINI_1_MODEL_NAME_PATTERN.test(extractModelName(modelString)); } /** - * Check if the model is a Gemini EAP or a Gemini 2.0+ model. + * Check if the model is a Gemini 2.x model using regex patterns. * - * EAP Gemini models do not encode a numeric version, so they are matched - * first by their naming convention — `gemini--early-exp` with an - * optional numeric suffix, e.g. `gemini-flash-early-exp` or - * `gemini-flash-early-exp3`. Otherwise the model name is parsed as a version - * and matches when the major version is >= 2. + * EAP models are deliberately not matched here: they carry no numeric version. + * Use {@link isGeminiEapOr2OrAbove} where they should be accepted. * * @param modelString Either a simple model name or path - based model name - * @return true if it's a Gemini EAP model or a Gemini 2.0+ model, false - * otherwise. + * @return true if it's a Gemini 2.x model, false otherwise. */ export function isGemini2OrAbove(modelString: string): boolean { if (!modelString) { @@ -137,10 +135,6 @@ export function isGemini2OrAbove(modelString: string): boolean { const modelName = extractModelName(modelString); - if (EAP_MODEL_NAME_PATTERN.test(modelName)) { - return true; - } - if (!modelName.startsWith('gemini-')) { return false; } @@ -151,6 +145,20 @@ export function isGemini2OrAbove(modelString: string): boolean { return parsedVersion.valid && parsedVersion.major >= 2; } +/** + * Check if the model is a Gemini EAP or a Gemini 2.0+ model. + * + * @param modelString Either a simple model name or path - based model name + * @return true if it's a Gemini EAP model or a Gemini 2.0+ model, false + * otherwise. + */ +export function isGeminiEapOr2OrAbove(modelString: string): boolean { + return ( + EAP_MODEL_NAME_PATTERN.test(extractModelName(modelString)) || + isGemini2OrAbove(modelString) + ); +} + /** * Check if the model is a Gemini 3.x Flash Live model. * diff --git a/core/test/runner/runner_test.ts b/core/test/runner/runner_test.ts index 904db5e3f..266479b37 100644 --- a/core/test/runner/runner_test.ts +++ b/core/test/runner/runner_test.ts @@ -7,6 +7,8 @@ import { App, BaseAgent, + BaseLlm, + BaseLlmConnection, BasePlugin, createEvent, createResumabilityConfig, @@ -17,6 +19,7 @@ import { InvocationContext, isRoutableLlmAgent, LlmAgent, + LlmResponse, Runner, } from '@google/adk'; import {Content, FunctionCall, FunctionResponse} from '@google/genai'; @@ -1241,3 +1244,60 @@ describe('Runner artifact saving (`saveInputBlobsAsArtifacts`)', () => { ]); }); }); + +/** + * A model stub carrying only an id, for gates that inspect the model name + * before any request is issued. + */ +class NamedModelStub extends BaseLlm { + async *generateContentAsync(): AsyncGenerator { + yield {content: {role: 'model', parts: [{text: ''}]}}; + } + + connect(): Promise { + return Promise.reject(new Error('connect is not supported by this stub')); + } +} + +describe('Runner CFC model gate', () => { + const sessionService = new InMemorySessionService(); + + async function runWithCfc(model: string): Promise { + const runner = new Runner({ + appName: TEST_APP_ID, + agent: new LlmAgent({ + name: 'cfc_agent', + model: new NamedModelStub({model}), + }), + sessionService, + }); + const session = await sessionService.createSession({ + appName: TEST_APP_ID, + userId: TEST_USER_ID, + }); + + const events: Event[] = []; + for await (const event of runner.runAsync({ + userId: session.userId, + sessionId: session.id, + newMessage: {role: 'user', parts: [{text: TEST_MESSAGE}]}, + runConfig: {supportCfc: true}, + })) { + events.push(event); + } + + return events; + } + + it('rejects an EAP model, matching the adk-python bare gemini-2 prefix gate', async () => { + await expect(runWithCfc('gemini-flash-early-exp')).rejects.toThrow( + 'CFC is not supported for model: gemini-flash-early-exp in agent: cfc_agent', + ); + }); + + it('rejects a Gemini 1.x model', async () => { + await expect(runWithCfc('gemini-1.5-pro')).rejects.toThrow( + 'CFC is not supported for model: gemini-1.5-pro in agent: cfc_agent', + ); + }); +}); diff --git a/core/test/tools/google_search_tool_test.ts b/core/test/tools/google_search_tool_test.ts index f304d94ba..dd2ab865b 100644 --- a/core/test/tools/google_search_tool_test.ts +++ b/core/test/tools/google_search_tool_test.ts @@ -4,10 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {GOOGLE_SEARCH, GoogleSearchTool, LlmRequest} from '@google/adk'; +import { + Context, + createSession, + GOOGLE_SEARCH, + GoogleSearchTool, + InvocationContext, + LlmAgent, + LlmRequest, + PluginManager, +} from '@google/adk'; +import {GenerateContentConfig} from '@google/genai'; import {describe, expect, it} from 'vitest'; -function makeRequest(model?: string, tools = []): LlmRequest { +function makeRequest( + model?: string, + tools: GenerateContentConfig['tools'] = [], +): LlmRequest { return { model, config: {tools}, @@ -17,6 +30,21 @@ function makeRequest(model?: string, tools = []): LlmRequest { } as unknown as LlmRequest; } +function makeToolContext(): Context { + return new Context({ + invocationContext: new InvocationContext({ + invocationId: 'google-search-test', + agent: new LlmAgent({name: 'google_search_test_agent'}), + session: createSession({ + id: 'test-session', + appName: 'test-app', + userId: 'test-user', + }), + pluginManager: new PluginManager([]), + }), + }); +} + describe('GoogleSearchTool', () => { describe('processLlmRequest', () => { it('returns early when model is not set', async () => { @@ -65,6 +93,35 @@ describe('GoogleSearchTool', () => { expect(req.config!.tools).toEqual([{googleSearch: {}}]); }); + const undottedGemini1Ids = ['gemini-1', 'gemini-1-pro', 'gemini-10.0-pro']; + + for (const model of undottedGemini1Ids) { + it(`adds googleSearch, not googleSearchRetrieval, for model: ${model}`, async () => { + const tool = new GoogleSearchTool(); + const req = makeRequest(model); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: makeToolContext(), + }); + + expect(req.config!.tools).toEqual([{googleSearch: {}}]); + }); + + it(`does not reject other tools alongside model: ${model}`, async () => { + const tool = new GoogleSearchTool(); + const req = makeRequest(model, [{functionDeclarations: []}]); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: makeToolContext(), + }); + + expect(req.config!.tools).toEqual([ + {functionDeclarations: []}, + {googleSearch: {}}, + ]); + }); + } + it('throws for unsupported (non-Gemini) model', async () => { const tool = new GoogleSearchTool(); const req = makeRequest('gpt-4'); diff --git a/core/test/tools/url_context_tool_test.ts b/core/test/tools/url_context_tool_test.ts index 4bebba3bf..b1d05231f 100644 --- a/core/test/tools/url_context_tool_test.ts +++ b/core/test/tools/url_context_tool_test.ts @@ -87,6 +87,52 @@ describe('UrlContextTool', () => { expect(req.config!.tools).toEqual([{urlContext: {}}]); }); + const extendedForms = [ + 'models/gemini-2.5-pro', + 'gemini/gemini-2.5-flash', + 'apigee/vertex_ai/v1beta/gemini-2.5-flash', + 'models/gemini-flash-early-exp', + ]; + + for (const model of extendedForms) { + it(`adds urlContext for model: ${model}`, async () => { + const tool = new UrlContextTool(); + const req = makeRequest(model); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: makeToolContext(), + }); + + expect(req.config!.tools).toEqual([{urlContext: {}}]); + }); + } + + const rejectedForms: Array<[string, string]> = [ + [ + 'openrouter/google/gemini-1.5-pro:online', + 'URL context tool requires Gemini 2 or above, but got openrouter/google/gemini-1.5-pro:online', + ], + [ + // Malformed Vertex path: the trailing segment must not be read as an + // id, so this stays a non-Gemini model. + 'projects/123/locations/us-central1/publishers/google/gemini-2.5-flash', + 'URL context tool is not supported for model projects/123/locations/us-central1/publishers/google/gemini-2.5-flash', + ], + ]; + + for (const [model, message] of rejectedForms) { + it(`throws for model: ${model}`, async () => { + const tool = new UrlContextTool(); + const req = makeRequest(model); + await expect( + tool.processLlmRequest({ + llmRequest: req, + toolContext: makeToolContext(), + }), + ).rejects.toThrow(message); + }); + } + it('throws for Gemini 1.x model', async () => { const tool = new UrlContextTool(); const req = makeRequest('gemini-1.5-pro'); diff --git a/core/test/utils/model_name_test.ts b/core/test/utils/model_name_test.ts index aee05e796..69500c591 100644 --- a/core/test/utils/model_name_test.ts +++ b/core/test/utils/model_name_test.ts @@ -9,6 +9,7 @@ import {describe, expect, it} from 'vitest'; import { extractModelName, isGemini1Model, + isGeminiEapOr2OrAbove, isGeminiModel, } from '../../src/utils/model_name.js'; @@ -178,6 +179,43 @@ describe('isGemini1Model', () => { expect(isGemini1Model('gemini/gemini-2.5-flash')).toBe(false); }); }); + + describe('version boundary', () => { + const gemini1Models = [ + 'gemini-1.5-flash', + 'gemini-1.0-pro', + 'gemini-1.5-pro-preview', + 'gemini-1.9-experimental', + 'projects/12345/locations/us-east1/publishers/google/models/gemini-1.0-pro-preview', + 'gemini/gemini-1.5-flash', + ]; + + for (const model of gemini1Models) { + it(`should return true for model: ${model}`, () => { + expect(isGemini1Model(model)).toBe(true); + }); + } + + const nonGemini1Models = [ + // A double-digit major must not be read as Gemini 1.x. + 'gemini-10.0-pro', + 'gemini-10-flash', + // The dotted minor version is mandatory. + 'gemini-1', + 'gemini-1-pro', + 'gemini-1.', + 'gemini-2.5-flash', + 'claude-3-sonnet', + 'my-gemini-1.5-model', + '', + ]; + + for (const model of nonGemini1Models) { + it(`should return false for model: ${model || ''}`, () => { + expect(isGemini1Model(model)).toBe(false); + }); + } + }); }); describe('isGemini2OrAbove', () => { @@ -227,6 +265,23 @@ describe('isGemini2OrAbove', () => { } }); + describe('EAP models', () => { + const eapModels = [ + 'gemini-flash-early-exp', + 'gemini-flash-early-exp3', + 'gemini-flash-lite-early-exp', + 'projects/my-project/locations/us-central1/publishers/google/models/gemini-flash-early-exp', + ]; + + for (const model of eapModels) { + it(`should return false for EAP model: ${model}`, () => { + expect(isGemini2OrAbove(model)).toBe(false); + }); + } + }); +}); + +describe('isGeminiEapOr2OrAbove', () => { describe('EAP models', () => { const eapModels = [ 'gemini-flash-early-exp', @@ -240,7 +295,7 @@ describe('isGemini2OrAbove', () => { for (const model of eapModels) { it(`should return true for EAP model: ${model}`, () => { - expect(isGemini2OrAbove(model)).toBe(true); + expect(isGeminiEapOr2OrAbove(model)).toBe(true); }); } @@ -256,15 +311,65 @@ describe('isGemini2OrAbove', () => { for (const model of nonEapModels) { it(`should return false for non-EAP model: ${model}`, () => { - expect(isGemini2OrAbove(model)).toBe(false); + expect(isGeminiEapOr2OrAbove(model)).toBe(false); }); } it('should not let the EAP pattern reclassify a Gemini 1.x model', () => { // The EAP character class excludes '.', so a 1.x name carrying the // suffix cannot match and stays below the 2.0 bar. - expect(isGemini2OrAbove('gemini-1.5-flash-early-exp')).toBe(false); + expect(isGeminiEapOr2OrAbove('gemini-1.5-flash-early-exp')).toBe(false); }); + + const eapPathForms = [ + 'models/gemini-flash-early-exp', + 'apigee/gemini-flash-early-exp', + 'gemini/gemini-flash-early-exp', + ]; + + for (const model of eapPathForms) { + it(`should return true for EAP model in path form: ${model}`, () => { + expect(isGeminiEapOr2OrAbove(model)).toBe(true); + }); + } + }); + + describe('numeric versions', () => { + const validModels = [ + 'gemini-2', + 'gemini-2-pro', + 'gemini-2.5-flash', + 'gemini-3.0-pro', + 'projects/12345/locations/us-east1/publishers/google/models/gemini-2.5-pro-preview', + 'models/gemini-2.5-pro', + 'apigee/v1/gemini-2.5-flash', + 'gemini/gemini-2.5-flash', + 'openrouter/google/gemini-2.5-pro:online', + ]; + + for (const model of validModels) { + it(`should return true for model: ${model}`, () => { + expect(isGeminiEapOr2OrAbove(model)).toBe(true); + }); + } + + const invalidModels = [ + 'gemini-1.5-flash', + 'gemini-1.0-pro', + 'openrouter/google/gemini-1.5-pro:online', + 'gemini-2.', + 'gemini-0.9-test', + 'gemini-one', + 'claude-3-sonnet', + '', + 'my-gemini-2.5-model', + ]; + + for (const model of invalidModels) { + it(`should return false for model: ${model || ''}`, () => { + expect(isGeminiEapOr2OrAbove(model)).toBe(false); + }); + } }); }); @@ -299,6 +404,57 @@ describe('isGemini2OrAbove with extended model id forms', () => { } }); +describe('classification consistency', () => { + const allModels = [ + 'gemini-1.5-flash', + 'gemini-2.5-flash', + 'gemini-3.0-pro', + 'gemini-flash-early-exp', + 'gemini/gemini-2.5-flash', + 'openrouter/google/gemini-2.5-pro:online', + 'apigee/gemini-2.5-flash', + 'models/gemini-2.5-pro', + 'claude-3-sonnet', + 'gpt-4', + ]; + + it('should never classify a model as both Gemini 1.x and Gemini EAP/2+', () => { + expect( + allModels.filter( + (model) => isGemini1Model(model) && isGeminiEapOr2OrAbove(model), + ), + ).toEqual([]); + }); + + it('should classify every version-matched model as a Gemini model', () => { + const versionMatched = allModels.filter( + (model) => isGemini1Model(model) || isGeminiEapOr2OrAbove(model), + ); + + expect(versionMatched.length).toBeGreaterThan(0); + expect(versionMatched.filter((model) => !isGeminiModel(model))).toEqual([]); + }); + + const bareNames = [ + 'gemini-1.5-flash', + 'gemini-2.5-flash', + 'gemini-3.0-pro', + 'claude-3-sonnet', + ]; + + for (const bareName of bareNames) { + it(`should classify the bare and path forms of ${bareName} identically`, () => { + const pathForm = `projects/12345/locations/us-central1/publishers/google/models/${bareName}`; + + expect(isGeminiModel(pathForm)).toBe(isGeminiModel(bareName)); + expect(isGemini1Model(pathForm)).toBe(isGemini1Model(bareName)); + expect(isGeminiEapOr2OrAbove(pathForm)).toBe( + isGeminiEapOr2OrAbove(bareName), + ); + }); + } +}); + describe('isGemini3xFlashLive', () => { it('should return true for valid Gemini 3.x Flash Live models', () => { expect(isGemini3xFlashLive('gemini-3.1-flash-live')).toBe(true);