Compare commits

...

6 Commits

Author SHA1 Message Date
Akhilesh Kumar 0a119e6ff7 fix(core): pass config to supportsModernFeatures to respect model definition flags 2026-04-16 20:36:07 +00:00
Akhilesh Kumar 1d5077cf0d fix(core): Correct Gemma 4 thought extraction and model features
Fixes an issue where the CLI hangs on 'Thinking...' for models (like Gemma 4) that return thought text in the 'thought' field instead of 'text'. Also updates Gemma 4 model definitions to accurately reflect their 'thinking' capabilities.
2026-04-16 19:16:22 +00:00
Akhilesh Kumar 5f181f96f8 perf(core): skip model routing classification when redundant 2026-04-16 18:38:43 +00:00
Akhilesh Kumar 44f9b590eb test: add integration test for Gemma 4 routing 2026-04-15 17:26:18 +00:00
Akhilesh Kumar 61096af29c docs: Add Gemma 4 routing instructions to README and docs 2026-04-15 01:57:31 +00:00
Akhilesh Kumar e781537294 feat(config): Redirect Gemini Pro and Flash requests to Gemma 4
Introduces a new `model.gemma4Variant` setting that allows users to optionally
redirect all requests destined for `gemini-pro` and `gemini-flash` (and their
related aliases) to the selected Gemma 4 variant (`gemma-4-26b-a4b-it` or
`gemma-4-31b-it`). The router model (`flash-lite`) remains unaffected.
2026-04-15 01:51:35 +00:00
23 changed files with 573 additions and 21 deletions
+3
View File
@@ -7,5 +7,8 @@
},
"general": {
"devtools": true
},
"model": {
"gemma4Variant": "gemma-4-31b-it"
}
}
+3
View File
@@ -16,6 +16,9 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
## 🚀 Why Gemini CLI?
- **🚀 Gemma 4 Support**: Route requests to Gemma 4 models (`gemma-4-26b-a4b-it`
or `gemma-4-31b-it`) using the `model.gemma4Variant` configuration setting.
- **🎯 Free tier**: 60 requests/min and 1,000 requests/day with personal Google
account.
- **🧠 Powerful Gemini 3 models**: Access to improved reasoning and 1M token
+48
View File
@@ -0,0 +1,48 @@
# Gemma 4 Routing
Gemini CLI allows you to seamlessly route your requests to Gemma 4 models. When
enabled, requests that would normally be sent to standard Gemini Pro and Flash
models are automatically redirected to your chosen Gemma 4 variant.
## Configuration
You can enable Gemma 4 routing using the CLI settings or by modifying your
`settings.json` file.
### Via Settings UI
1. Open the settings dialog by running `/settings`.
2. Navigate to the **Model** section.
3. Locate the **Gemma 4 Variant** setting.
4. Select your preferred model:
- `gemma-4-26b-a4b-it` (Gemma 4 26B A4B IT)
- `gemma-4-31b-it` (Gemma 4 31B IT)
5. Save the settings and restart the CLI if prompted.
### Via `settings.json`
You can also set this directly in your `.gemini/settings.json` file:
```json
{
"model": {
"gemma4Variant": "gemma-4-31b-it"
}
}
```
## How it works
When a `gemma4Variant` is selected, Gemini CLI intercepts model resolution:
- Requests for `gemini-pro`, `gemini-flash`, and their associated aliases (like
`auto`, `pro`, `flash`) are routed to the selected Gemma 4 model.
- The **router model** (`flash-lite`) remains unaffected and continues to use
`gemini-2.5-flash-lite`. This ensures fast, lightweight background routing
tasks continue to operate optimally.
- If you do not have preview model access, the CLI normally falls back to stable
models; however, the Gemma 4 variant will still take precedence for Pro and
Flash targets.
To disable Gemma 4 routing, simply remove the `gemma4Variant` configuration from
your settings or set it to `undefined`/empty in the UI.
+4
View File
@@ -171,6 +171,10 @@
"label": "Project context (GEMINI.md)",
"slug": "docs/cli/gemini-md"
},
{
"label": "Gemma 4 Routing",
"slug": "docs/cli/gemma4-routing"
},
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "System prompt override",
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am Gemma 4."}],"role":"model"},"index":0,"finishReason":"STOP"}]}]}
+52
View File
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { TestRig } from './test-helper.js';
describe('gemma4 routing', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
await rig.cleanup();
});
it('routes gemini-pro to gemma-4-31b-it when configured', async () => {
await rig.setup('gemma4-routing', {
fakeResponsesPath: join(
dirname(fileURLToPath(import.meta.url)),
'gemma4-routing.responses',
),
settings: {
model: {
gemma4Variant: 'gemma-4-31b-it',
},
},
});
// We don't need real responses since we're just checking the routing/telemetry
// But TestRig might require them if it actually tries to call the API.
// Let's use a simple prompt.
await rig.run({
args: ['--model', 'gemini-2.5-pro', 'Hello!'],
});
const hasApiRequestEvent = await rig.waitForTelemetryEvent('api_request');
expect(hasApiRequestEvent).toBe(true);
const lastRequest = rig.readLastApiRequest();
expect(lastRequest).not.toBeNull();
// The telemetry logger records the final requested model ID in the 'model' attribute
expect(lastRequest?.attributes?.model).toBe('gemma-4-31b-it');
});
});
+1
View File
@@ -913,6 +913,7 @@ export async function loadCliConfig(
debugMode,
question,
worktreeSettings,
gemma4Variant: settings.model?.gemma4Variant,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
+17
View File
@@ -1047,6 +1047,23 @@ const SETTINGS_SCHEMA = {
description: 'Skip the next speaker check.',
showInDialog: true,
},
gemma4Variant: {
type: 'enum',
label: 'Gemma 4 Variant',
category: 'Model',
requiresRestart: true,
default: undefined as
| 'gemma-4-26b-a4b-it'
| 'gemma-4-31b-it'
| undefined,
description:
'Select which Gemma 4 model variant to use when routing Gemini Pro and Flash requests.',
showInDialog: true,
options: [
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
],
},
},
},
@@ -53,7 +53,7 @@ export const CodebaseInvestigatorAgent = (
): LocalAgentDefinition<typeof CodebaseInvestigationReportSchema> => {
// Use Preview Flash model if the main model supports modern features.
// If the main model is not a modern model, use the default pro model.
const model = supportsModernFeatures(config.getModel())
const model = supportsModernFeatures(config.getModel(), config)
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_MODEL;
@@ -97,7 +97,7 @@ export const CodebaseInvestigatorAgent = (
generateContentConfig: {
temperature: 0.1,
topP: 0.95,
thinkingConfig: supportsModernFeatures(model)
thinkingConfig: supportsModernFeatures(model, config)
? {
includeThoughts: true,
thinkingLevel: ThinkingLevel.HIGH,
+10
View File
@@ -726,6 +726,7 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
gemma4Variant?: 'gemma-4-26b-a4b-it' | 'gemma-4-31b-it';
}
export class Config implements McpContext, AgentLoopContext {
@@ -759,6 +760,10 @@ export class Config implements McpContext, AgentLoopContext {
private readonly debugMode: boolean;
private readonly question: string | undefined;
private readonly worktreeSettings: WorktreeSettings | undefined;
private readonly gemma4Variant:
| 'gemma-4-26b-a4b-it'
| 'gemma-4-31b-it'
| undefined;
readonly enableConseca: boolean;
private readonly coreTools: string[] | undefined;
@@ -1027,6 +1032,7 @@ export class Config implements McpContext, AgentLoopContext {
this.debugMode = params.debugMode;
this.question = params.question;
this.worktreeSettings = params.worktreeSettings;
this.gemma4Variant = params.gemma4Variant;
this.coreTools = params.coreTools;
this.mainAgentTools = params.mainAgentTools;
@@ -1755,6 +1761,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.worktreeSettings;
}
getGemma4Variant(): 'gemma-4-26b-a4b-it' | 'gemma-4-31b-it' | undefined {
return this.gemma4Variant;
}
getClientName(): string | undefined {
return this.clientName;
}
@@ -89,6 +89,18 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-2.5-flash-lite',
},
},
'gemma-4-26b-a4b-it': {
extends: 'chat-base',
modelConfig: {
model: 'gemma-4-26b-a4b-it',
},
},
'gemma-4-31b-it': {
extends: 'chat-base',
modelConfig: {
model: 'gemma-4-31b-it',
},
},
// Bases for the internal model configs.
'gemini-2.5-flash-base': {
extends: 'base',
@@ -317,6 +329,20 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
isVisible: true,
features: { thinking: false, multimodalToolUse: false },
},
'gemma-4-26b-a4b-it': {
tier: 'pro',
family: 'gemma-4',
isPreview: true,
isVisible: true,
features: { thinking: true, multimodalToolUse: false },
},
'gemma-4-31b-it': {
tier: 'pro',
family: 'gemma-4',
isPreview: true,
isVisible: true,
features: { thinking: false, multimodalToolUse: false },
},
// Aliases
auto: {
tier: 'auto',
@@ -362,9 +388,43 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
modelIdResolutions: {
'gemini-2.5-flash': {
default: 'gemini-2.5-flash',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
],
},
'gemini-2.5-pro': {
default: 'gemini-2.5-pro',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
],
},
'gemini-3.1-pro-preview': {
default: 'gemini-3.1-pro-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useCustomTools: true },
@@ -375,12 +435,28 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'gemini-3.1-pro-preview-customtools': {
default: 'gemini-3.1-pro-preview-customtools',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
],
},
'gemini-3-flash-preview': {
default: 'gemini-3-flash-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
@@ -390,6 +466,14 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'gemini-3-pro-preview': {
default: 'gemini-3-pro-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useGemini3_1: true, useCustomTools: true },
@@ -404,6 +488,14 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'auto-gemini-3': {
default: 'gemini-3-pro-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useGemini3_1: true, useCustomTools: true },
@@ -418,6 +510,14 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
auto: {
default: 'gemini-3-pro-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useGemini3_1: true, useCustomTools: true },
@@ -432,6 +532,14 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
pro: {
default: 'gemini-3-pro-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
{
condition: { useGemini3_1: true, useCustomTools: true },
@@ -445,6 +553,16 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
'auto-gemini-2.5': {
default: 'gemini-2.5-pro',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
],
},
'gemini-3.1-flash-lite-preview': {
default: 'gemini-3.1-flash-lite-preview',
@@ -458,6 +576,14 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
flash: {
default: 'gemini-3-flash-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
@@ -478,6 +604,22 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
flash: {
default: 'gemini-3-flash-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{
condition: { requestedModels: ['auto-gemini-2.5', 'gemini-2.5-pro'] },
target: 'gemini-2.5-flash',
@@ -493,6 +635,22 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
pro: {
default: 'gemini-3-pro-preview',
contexts: [
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{
condition: { gemma4Variant: 'gemma-4-26b-a4b-it' },
target: 'gemma-4-26b-a4b-it',
},
{
condition: { gemma4Variant: 'gemma-4-31b-it' },
target: 'gemma-4-31b-it',
},
{
condition: { requestedModels: ['auto-gemini-2.5', 'gemini-2.5-pro'] },
target: 'gemini-2.5-pro',
+31 -2
View File
@@ -10,6 +10,7 @@ export interface ModelResolutionContext {
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
requestedModel?: string;
gemma4Variant?: 'gemma-4-26b-a4b-it' | 'gemma-4-31b-it';
}
/**
@@ -48,6 +49,7 @@ export interface IModelConfigService {
export interface ModelCapabilityContext {
readonly modelConfigService: IModelConfigService;
getExperimentalDynamicModelConfiguration(): boolean;
getGemma4Variant?: () => 'gemma-4-26b-a4b-it' | 'gemma-4-31b-it' | undefined;
}
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
@@ -60,6 +62,8 @@ export const PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL =
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const GEMMA_4_26B_IT = 'gemma-4-26b-a4b-it';
export const GEMMA_4_31B_IT = 'gemma-4-31b-it';
export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_MODEL,
@@ -70,6 +74,8 @@ export const VALID_GEMINI_MODELS = new Set([
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMMA_4_26B_IT,
GEMMA_4_31B_IT,
]);
export const PREVIEW_GEMINI_MODEL_AUTO = 'auto-gemini-3';
@@ -109,6 +115,7 @@ export function resolveModel(
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
gemma4Variant: config.getGemma4Variant?.(),
});
if (!hasAccessToPreview && isPreviewModel(resolved, config)) {
@@ -160,6 +167,17 @@ export function resolveModel(
}
}
const variant = config?.getGemma4Variant?.();
if (
variant &&
(resolved === PREVIEW_GEMINI_MODEL ||
resolved === DEFAULT_GEMINI_MODEL ||
resolved === PREVIEW_GEMINI_FLASH_MODEL ||
resolved === DEFAULT_GEMINI_FLASH_MODEL)
) {
return variant;
}
if (!hasAccessToPreview && isPreviewModel(resolved)) {
// Downgrade to stable models if user lacks preview access.
switch (resolved) {
@@ -214,6 +232,7 @@ export function resolveClassifierModel(
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
gemma4Variant: config.getGemma4Variant?.(),
},
);
}
@@ -378,11 +397,21 @@ export function isCustomModel(
* This includes Gemini 3 models and any custom models.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports modern features like thoughts.
*/
export function supportsModernFeatures(model: string): boolean {
export function supportsModernFeatures(
model: string,
config?: ModelCapabilityContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const definition = config.modelConfigService.getModelDefinition(model);
if (definition?.features?.thinking !== undefined) {
return definition.features.thinking;
}
}
if (isGemini3Model(model)) return true;
return isCustomModel(model);
return isCustomModel(model, config);
}
/**
+14 -4
View File
@@ -569,7 +569,10 @@ export class GeminiChat {
abortSignal,
};
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
let contentsToUse: Content[] = supportsModernFeatures(
modelToUse,
this.context.config,
)
? [...contentsForPreviewModel]
: [...requestContents];
@@ -613,7 +616,10 @@ export class GeminiChat {
);
lastModelToUse = modelToUse;
// Re-evaluate contentsToUse based on the new model's feature support
contentsToUse = supportsModernFeatures(modelToUse)
contentsToUse = supportsModernFeatures(
modelToUse,
this.context.config,
)
? [...contentsForPreviewModel]
: [...requestContents];
}
@@ -1072,9 +1078,13 @@ export class GeminiChat {
}
const thoughtPart = content.parts[0];
if (thoughtPart.text) {
const rawText =
typeof thoughtPart.thought === 'string'
? thoughtPart.thought
: (thoughtPart.text ?? '');
if (rawText) {
// Extract subject and description using the same logic as turn.ts
const rawText = thoughtPart.text;
const subjectStringMatches = rawText.match(/\*\*(.*?)\*\*/s);
const subject = subjectStringMatches
? subjectStringMatches[1].trim()
+2 -2
View File
@@ -733,7 +733,7 @@ describe('Turn', () => {
},
{
description: 'should yield thought events with traceId',
part: { text: '[Thought: thinking]', thought: 'thinking' },
part: { text: '[Thought: thinking]', thought: true },
responseId: 'trace-456',
expectedEvent: {
type: GeminiEventType.Thought,
@@ -774,7 +774,7 @@ describe('Turn', () => {
{
content: {
parts: [
{ text: '**Planning** the solution', thought: 'planning' },
{ text: '**Planning** the solution', thought: true },
{ text: 'I will help you with that.' },
],
},
+5 -1
View File
@@ -308,7 +308,11 @@ export class Turn {
const parts = resp.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.thought) {
const thought = parseThought(part.text ?? '');
const rawThought =
typeof part.thought === 'string'
? part.thought
: (part.text ?? '');
const thought = parseThought(rawThought);
yield {
type: GeminiEventType.Thought,
value: thought,
+2 -2
View File
@@ -69,7 +69,7 @@ export class PromptProvider {
context.config.getHasAccessToPreviewModel?.() ?? true,
context.config,
);
const isModernModel = supportsModernFeatures(desiredModel);
const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
@@ -280,7 +280,7 @@ export class PromptProvider {
context.config.getHasAccessToPreviewModel?.() ?? true,
context.config,
);
const isModernModel = supportsModernFeatures(desiredModel);
const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt(
context.config.getApprovedPlanPath(),
@@ -383,6 +383,56 @@ describe('ClassifierStrategy', () => {
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should skip classification if both pro and flash resolve to the same model', async () => {
// We mock the config to trigger the fast path by returning a specific model
// that the router will see as identical for both 'pro' and 'flash' tiers.
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
// By overriding the modelConfigService, we can simulate gemma4Variant
// or any other scenario where both tiers resolve to the same target model.
const mockResolveClassifierModelId = vi
.fn()
.mockReturnValue('gemma-4-31b-it');
Object.defineProperty(
mockConfig.modelConfigService,
'resolveClassifierModelId',
{
value: mockResolveClassifierModelId,
writable: true,
},
);
// We also need to mock config.getExperimentalDynamicModelConfiguration()
// if that is what resolveClassifierModel uses. Since resolveClassifierModel
// is a standalone function, we can mock its behavior indirectly via config.
Object.defineProperty(
mockConfig,
'getExperimentalDynamicModelConfiguration',
{
value: vi.fn().mockReturnValue(true),
writable: true,
},
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).toEqual({
model: 'gemma-4-31b-it',
metadata: {
source: 'classifier',
latencyMs: 0,
reasoning:
'Skipped classification because both tiers resolve to the same model: gemma-4-31b-it',
},
});
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
describe('Gemini 3.1 and Custom Tools Routing', () => {
it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
@@ -137,6 +137,47 @@ export class ClassifierStrategy implements RoutingStrategy {
const startTime = Date.now();
try {
const model = context.requestedModel ?? config.getModel();
const [useGemini3_1, useGemini3_1FlashLite, useCustomToolModel] =
await Promise.all([
config.getGemini31Launched(),
config.getGemini31FlashLiteLaunched(),
config.getUseCustomToolModel(),
]);
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
// Check if classification is redundant (i.e., both tiers resolve to the same model)
const proModel = resolveClassifierModel(
model,
'pro',
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
);
const flashModel = resolveClassifierModel(
model,
'flash',
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
);
if (proModel === flashModel) {
return {
model: proModel,
metadata: {
source: this.name,
latencyMs: 0,
reasoning: `Skipped classification because both tiers resolve to the same model: ${proModel}`,
},
};
}
if (
(await config.getNumericalRoutingEnabled()) &&
isGemini3Model(model, config)
@@ -171,19 +212,13 @@ export class ClassifierStrategy implements RoutingStrategy {
const reasoning = routerResponse.reasoning;
const latencyMs = Date.now() - startTime;
const [useGemini3_1, useGemini3_1FlashLite, useCustomToolModel] =
await Promise.all([
config.getGemini31Launched(),
config.getGemini31FlashLiteLaunched(),
config.getUseCustomToolModel(),
]);
const selectedModel = resolveClassifierModel(
model,
routerResponse.model_choice,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
config.getHasAccessToPreviewModel?.() ?? true,
hasAccessToPreview,
config,
);
@@ -32,6 +32,9 @@ describe('GemmaClassifierStrategy', () => {
mockGenerateJson = vi.fn();
mockConfig = {
modelConfigService: {
resolveClassifierModelId: vi.fn(),
},
getGemmaModelRouterSettings: vi.fn().mockReturnValue({
enabled: true,
classifier: { model: 'gemma3-1b-gpu-custom' },
@@ -83,6 +86,44 @@ describe('GemmaClassifierStrategy', () => {
).rejects.toThrow('Only gemma3-1b-gpu-custom has been tested');
});
it('should skip classification if both pro and flash resolve to the same model', async () => {
// Setup the mock config to use the dynamic model config service.
Object.defineProperty(
mockConfig,
'getExperimentalDynamicModelConfiguration',
{
value: vi.fn().mockReturnValue(true),
writable: true,
},
);
Object.defineProperty(
mockConfig.modelConfigService,
'resolveClassifierModelId',
{
value: vi.fn().mockReturnValue('gemma-4-31b-it'),
writable: true,
},
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).toEqual({
model: 'gemma-4-31b-it',
metadata: {
source: 'gemma-classifier',
latencyMs: 0,
reasoning:
'Skipped classification because both tiers resolve to the same model: gemma-4-31b-it',
},
});
expect(mockGenerateJson).not.toHaveBeenCalled();
});
it('should call generateJson with the correct parameters', async () => {
const mockApiResponse = {
reasoning: 'Simple task',
@@ -178,6 +178,39 @@ ${formattedHistory}
return null;
}
const model = context.requestedModel ?? config.getModel();
// Check if classification is redundant (i.e., both tiers resolve to the same model)
const proModel = resolveClassifierModel(
model,
'pro',
false, // useGemini3_1
false, // useGemini3_1FlashLite
false, // useCustomToolModel
true, // hasAccessToPreview
config,
);
const flashModel = resolveClassifierModel(
model,
'flash',
false, // useGemini3_1
false, // useGemini3_1FlashLite
false, // useCustomToolModel
true, // hasAccessToPreview
config,
);
if (proModel === flashModel) {
return {
model: proModel,
metadata: {
source: this.name,
latencyMs: 0,
reasoning: `Skipped classification because both tiers resolve to the same model: ${proModel}`,
},
};
}
// Only the gemma3-1b-gpu-custom model has been tested and verified.
if (gemmaRouterSettings.classifier?.model !== 'gemma3-1b-gpu-custom') {
throw new Error('Only gemma3-1b-gpu-custom has been tested');
@@ -210,8 +243,13 @@ ${formattedHistory}
const reasoning = routerResponse.reasoning;
const latencyMs = Date.now() - startTime;
const selectedModel = resolveClassifierModel(
context.requestedModel ?? config.getModel(),
model,
routerResponse.model_choice,
false, // useGemini3_1
false, // useGemini3_1FlashLite
false, // useCustomToolModel
true, // hasAccessToPreview
config,
);
return {
@@ -101,6 +101,7 @@ export interface ResolutionContext {
hasAccessToPreview?: boolean;
hasAccessToProModel?: boolean;
requestedModel?: string;
gemma4Variant?: 'gemma-4-26b-a4b-it' | 'gemma-4-31b-it';
}
/** The requirements defined in the registry. */
@@ -109,6 +110,7 @@ export interface ResolutionCondition {
useGemini3_1FlashLite?: boolean;
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
gemma4Variant?: 'gemma-4-26b-a4b-it' | 'gemma-4-31b-it';
/** Matches if the current model is in this list. */
requestedModels?: string[];
}
@@ -252,6 +254,8 @@ export class ModelConfigService {
return value === context.useCustomTools;
case 'hasAccessToPreview':
return value === context.hasAccessToPreview;
case 'gemma4Variant':
return value === context.gemma4Variant;
case 'requestedModels':
return (
Array.isArray(value) &&
@@ -97,6 +97,28 @@
"topK": 64
}
},
"gemma-4-26b-a4b-it": {
"model": "gemma-4-26b-a4b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true
},
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true
},
"topK": 64
}
},
"gemini-2.5-flash-base": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
@@ -97,6 +97,28 @@
"topK": 64
}
},
"gemma-4-26b-a4b-it": {
"model": "gemma-4-26b-a4b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true
},
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true
},
"topK": 64
}
},
"gemini-2.5-flash-base": {
"model": "gemini-2.5-flash",
"generateContentConfig": {