Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions core/src/code_executors/built_in_code_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand All @@ -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: {}});
Expand Down
8 changes: 4 additions & 4 deletions core/src/tools/url_context_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}`,
);
Expand Down
52 changes: 30 additions & 22 deletions core/src/utils/model_name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/[<provider>/][<version>/]<model_id>`). Declared
* without the `g` flag so `.match()` stays stateless.
* and the Apigee path (`apigee/[<provider>/][<version>/]<model_id>`).
*/
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.
Expand All @@ -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
Expand Down Expand Up @@ -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-<variant>-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) {
Expand All @@ -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;
}
Expand All @@ -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.
*
Expand Down
60 changes: 60 additions & 0 deletions core/test/runner/runner_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import {
App,
BaseAgent,
BaseLlm,
BaseLlmConnection,
BasePlugin,
createEvent,
createResumabilityConfig,
Expand All @@ -17,6 +19,7 @@ import {
InvocationContext,
isRoutableLlmAgent,
LlmAgent,
LlmResponse,
Runner,
} from '@google/adk';
import {Content, FunctionCall, FunctionResponse} from '@google/genai';
Expand Down Expand Up @@ -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<LlmResponse, void, void> {
yield {content: {role: 'model', parts: [{text: ''}]}};
}

connect(): Promise<BaseLlmConnection> {
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<Event[]> {
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',
);
});
});
61 changes: 59 additions & 2 deletions core/test/tools/google_search_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down
46 changes: 46 additions & 0 deletions core/test/tools/url_context_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading