feat(routing): availability-aware auto-routing with best-effort pro

Adds settings and logic to detect slow/hanging Pro model requests, marking them as temporarily unavailable and automatically triggering a fallback to Flash. Introduces a proTimeoutMinutes and bestEffortPro strategy configuration.
This commit is contained in:
jacob314
2026-04-23 13:50:24 -07:00
parent ff28d55100
commit 19601955fd
15 changed files with 269 additions and 44 deletions
+1
View File
@@ -1044,6 +1044,7 @@ export async function loadCliConfig(
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
autoRouting: settings.model?.autoRouting,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
+40
View File
@@ -1112,6 +1112,46 @@ const SETTINGS_SCHEMA = {
description: 'Skip the next speaker check.',
showInDialog: true,
},
autoRouting: {
type: 'object',
label: 'Auto Routing',
category: 'Model',
requiresRestart: false,
default: {},
description: 'Settings for automatic model routing.',
showInDialog: false,
properties: {
bestEffortPro: {
type: 'boolean',
label: 'Best Effort Pro',
category: 'Model',
requiresRestart: false,
default: false,
description:
'Always prefer the Pro model unless it is unavailable (e.g., due to timeouts or quota), ignoring other routing hints.',
showInDialog: true,
},
proTimeoutMinutes: {
type: 'number',
label: 'Pro Timeout (Minutes)',
category: 'Model',
requiresRestart: false,
default: 5,
description:
'If a Pro request takes longer than this many minutes, it will be marked as temporarily unavailable and fallback to Flash.',
showInDialog: true,
},
proTimeoutFallbackDurationMinutes: {
type: 'number',
label: 'Pro Timeout Fallback Duration (Minutes)',
category: 'Model',
requiresRestart: false,
default: 60,
description: 'How long to route to Flash after Pro times out.',
showInDialog: true,
},
},
},
},
},
@@ -124,6 +124,9 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
getUserCaching: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getBestEffortProEnabled: vi.fn().mockResolvedValue(false),
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
@@ -8,13 +8,15 @@ export type ModelId = string;
type TerminalUnavailabilityReason = 'quota' | 'capacity';
export type TurnUnavailabilityReason = 'retry_once_per_turn';
export type TemporaryUnavailabilityReason = 'timeout';
export type UnavailabilityReason =
| TerminalUnavailabilityReason
| TurnUnavailabilityReason
| TemporaryUnavailabilityReason
| 'unknown';
export type ModelHealthStatus = 'terminal' | 'sticky_retry';
export type ModelHealthStatus = 'terminal' | 'sticky_retry' | 'temporary';
type HealthState =
| { status: 'terminal'; reason: TerminalUnavailabilityReason }
@@ -22,6 +24,11 @@ type HealthState =
status: 'sticky_retry';
reason: TurnUnavailabilityReason;
consumed: boolean;
}
| {
status: 'temporary';
reason: TemporaryUnavailabilityReason;
untilMs: number;
};
export interface ModelAvailabilitySnapshot {
@@ -48,6 +55,18 @@ export class ModelAvailabilityService {
});
}
markTemporarilyUnavailable(
model: ModelId,
reason: TemporaryUnavailabilityReason,
durationMs: number,
) {
this.setState(model, {
status: 'temporary',
reason,
untilMs: Date.now() + durationMs,
});
}
markHealthy(model: ModelId) {
this.clearState(model);
}
@@ -95,6 +114,15 @@ export class ModelAvailabilityService {
return { available: false, reason: state.reason };
}
if (state.status === 'temporary') {
if (Date.now() < state.untilMs) {
return { available: false, reason: state.reason };
} else {
this.clearState(model);
return { available: true };
}
}
return { available: true };
}
+25
View File
@@ -679,6 +679,11 @@ export interface ConfigParameters {
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
gemmaModelRouter?: GemmaModelRouterSettings;
autoRouting?: {
bestEffortPro?: boolean;
proTimeoutMinutes?: number;
proTimeoutFallbackDurationMinutes?: number;
};
adk?: ADKSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
@@ -963,6 +968,9 @@ export class Config implements McpContext, AgentLoopContext {
private readonly planEnabled: boolean;
private readonly trackerEnabled: boolean;
private readonly planModeRoutingEnabled: boolean;
private readonly autoRoutingBestEffortPro: boolean;
private readonly autoRoutingProTimeoutMinutes: number;
private readonly autoRoutingProTimeoutFallbackDurationMinutes: number;
private readonly modelSteering: boolean;
private memoryContextManager?: MemoryContextManager;
private readonly contextManagement: ContextManagementConfig;
@@ -1117,6 +1125,11 @@ export class Config implements McpContext, AgentLoopContext {
this.planEnabled = params.plan ?? true;
this.trackerEnabled = params.tracker ?? false;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.autoRoutingBestEffortPro = params.autoRouting?.bestEffortPro ?? false;
this.autoRoutingProTimeoutMinutes =
params.autoRouting?.proTimeoutMinutes ?? 5;
this.autoRoutingProTimeoutFallbackDurationMinutes =
params.autoRouting?.proTimeoutFallbackDurationMinutes ?? 60;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
@@ -3144,6 +3157,18 @@ export class Config implements McpContext, AgentLoopContext {
return flag?.boolValue ?? true;
}
async getBestEffortProEnabled(): Promise<boolean> {
return this.autoRoutingBestEffortPro;
}
async getProTimeoutMinutes(): Promise<number> {
return this.autoRoutingProTimeoutMinutes;
}
async getProTimeoutFallbackDurationMinutes(): Promise<number> {
return this.autoRoutingProTimeoutFallbackDurationMinutes;
}
/**
* Returns the resolved complexity threshold for routing.
* If a remote threshold is provided and within range (0-100), it is returned.
@@ -109,6 +109,8 @@ describe('BaseLlmClient', () => {
.mockReturnValue({ authType: AuthType.USE_GEMINI }),
getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'),
isInteractive: vi.fn().mockReturnValue(false),
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
modelConfigService: {
getResolvedConfig: vi
.fn()
+8
View File
@@ -325,11 +325,19 @@ export class BaseLlmClient {
);
};
const proTimeoutMinutes = await this.config.getProTimeoutMinutes();
const proTimeoutFallbackDurationMinutes =
await this.config.getProTimeoutFallbackDurationMinutes();
return await retryWithBackoff(apiCall, {
shouldRetryOnContent,
maxAttempts:
availabilityMaxAttempts ?? maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
getAvailabilityContext,
timeoutFallback: {
timeoutMs: proTimeoutMinutes * 60 * 1000,
fallbackDurationMs: proTimeoutFallbackDurationMinutes * 60 * 1000,
},
onPersistent429: this.config.isInteractive()
? (authType, error) =>
handleFallback(this.config, currentModel, authType, error)
@@ -160,6 +160,8 @@ describe('GeminiChat', () => {
authType: 'oauth-personal',
model: currentModel,
})),
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
getModel: vi.fn().mockImplementation(() => currentModel),
setModel: vi.fn().mockImplementation((m: string) => {
currentModel = m;
+8
View File
@@ -687,6 +687,10 @@ export class GeminiChat {
);
};
const proTimeoutMinutes = await this.context.config.getProTimeoutMinutes();
const proTimeoutFallbackDurationMinutes =
await this.context.config.getProTimeoutFallbackDurationMinutes();
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
@@ -696,6 +700,10 @@ export class GeminiChat {
maxAttempts:
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
getAvailabilityContext,
timeoutFallback: {
timeoutMs: proTimeoutMinutes * 60 * 1000,
fallbackDurationMs: proTimeoutFallbackDurationMinutes * 60 * 1000,
},
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
@@ -103,6 +103,8 @@ describe('GeminiChat Network Retries', () => {
authType: 'oauth-personal',
model: 'test-model',
}),
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
setActiveModel: vi.fn(),
@@ -32,6 +32,9 @@ vi.mock('./strategies/overrideStrategy.js');
vi.mock('./strategies/approvalModeStrategy.js');
vi.mock('./strategies/classifierStrategy.js');
vi.mock('./strategies/numericalClassifierStrategy.js');
import { BestEffortProStrategy } from './strategies/bestEffortProStrategy.js';
vi.mock('./strategies/bestEffortProStrategy.js');
vi.mock('./strategies/gemmaClassifierStrategy.js');
vi.mock('../telemetry/loggers.js');
vi.mock('../telemetry/types.js');
@@ -74,6 +77,7 @@ describe('ModelRouterService', () => {
[
new FallbackStrategy(),
new OverrideStrategy(),
new BestEffortProStrategy(),
new ApprovalModeStrategy(),
new ClassifierStrategy(),
new NumericalClassifierStrategy(),
@@ -104,13 +108,14 @@ describe('ModelRouterService', () => {
const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0];
const childStrategies = compositeStrategyArgs[0];
expect(childStrategies.length).toBe(6);
expect(childStrategies.length).toBe(7);
expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy);
expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy);
expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy);
expect(childStrategies[3]).toBeInstanceOf(ClassifierStrategy);
expect(childStrategies[4]).toBeInstanceOf(NumericalClassifierStrategy);
expect(childStrategies[5]).toBeInstanceOf(DefaultStrategy);
expect(childStrategies[2]).toBeInstanceOf(BestEffortProStrategy);
expect(childStrategies[3]).toBeInstanceOf(ApprovalModeStrategy);
expect(childStrategies[4]).toBeInstanceOf(ClassifierStrategy);
expect(childStrategies[5]).toBeInstanceOf(NumericalClassifierStrategy);
expect(childStrategies[6]).toBeInstanceOf(DefaultStrategy);
expect(compositeStrategyArgs[1]).toBe('agent-router');
});
@@ -133,14 +138,15 @@ describe('ModelRouterService', () => {
const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0];
const childStrategies = compositeStrategyArgs[0];
expect(childStrategies.length).toBe(7);
expect(childStrategies.length).toBe(8);
expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy);
expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy);
expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy);
expect(childStrategies[3]).toBeInstanceOf(GemmaClassifierStrategy);
expect(childStrategies[4]).toBeInstanceOf(ClassifierStrategy);
expect(childStrategies[5]).toBeInstanceOf(NumericalClassifierStrategy);
expect(childStrategies[6]).toBeInstanceOf(DefaultStrategy);
expect(childStrategies[2]).toBeInstanceOf(BestEffortProStrategy);
expect(childStrategies[3]).toBeInstanceOf(ApprovalModeStrategy);
expect(childStrategies[4]).toBeInstanceOf(GemmaClassifierStrategy);
expect(childStrategies[5]).toBeInstanceOf(ClassifierStrategy);
expect(childStrategies[6]).toBeInstanceOf(NumericalClassifierStrategy);
expect(childStrategies[7]).toBeInstanceOf(DefaultStrategy);
expect(compositeStrategyArgs[1]).toBe('agent-router');
});
@@ -18,6 +18,7 @@ import { NumericalClassifierStrategy } from './strategies/numericalClassifierStr
import { CompositeStrategy } from './strategies/compositeStrategy.js';
import { FallbackStrategy } from './strategies/fallbackStrategy.js';
import { OverrideStrategy } from './strategies/overrideStrategy.js';
import { BestEffortProStrategy } from './strategies/bestEffortProStrategy.js';
import { ApprovalModeStrategy } from './strategies/approvalModeStrategy.js';
import { logModelRouting } from '../telemetry/loggers.js';
@@ -43,6 +44,9 @@ export class ModelRouterService {
strategies.push(new FallbackStrategy());
strategies.push(new OverrideStrategy());
// Best Effort Pro is next.
strategies.push(new BestEffortProStrategy());
// Approval mode is next.
strategies.push(new ApprovalModeStrategy());
@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../../config/config.js';
import { isAutoModel, resolveModel } from '../../config/models.js';
import type {
RoutingStrategy,
RoutingDecision,
RoutingContext,
} from '../routingStrategy.js';
/**
* A routing strategy that respects the "Best Effort Pro" setting.
* If the setting is enabled and the Pro model is available, it routes to Pro
* regardless of complexity. If Pro is unavailable, it routes to Flash.
*/
export class BestEffortProStrategy implements RoutingStrategy {
name = 'best-effort-pro';
async route(
context: RoutingContext,
config: Config,
): Promise<RoutingDecision | null> {
const requestedModel = config.getModel();
if (!isAutoModel(requestedModel)) {
return null;
}
const isBestEffortProEnabled = await config.getBestEffortProEnabled();
if (!isBestEffortProEnabled) {
return null;
}
const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false;
const useGemini3_1FlashLite =
(await config.getGemini31FlashLiteLaunched?.()) ?? false;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
const availabilityService = config.getModelAvailabilityService();
const proModel = resolveModel(
'gemini-3.1-pro',
useGemini3_1,
useGemini3_1FlashLite,
false,
hasAccessToPreview,
config,
);
const flashModel = resolveModel(
'gemini-3.1-flash',
useGemini3_1,
useGemini3_1FlashLite,
false,
hasAccessToPreview,
config,
);
const proSnapshot = availabilityService.snapshot(proModel);
if (proSnapshot.available) {
return {
model: proModel,
metadata: {
source: this.name,
latencyMs: 0,
reasoning:
'Best Effort Pro is enabled and the Pro model is available.',
},
};
} else {
return {
model: flashModel,
metadata: {
source: this.name,
latencyMs: 0,
reasoning: `Best Effort Pro is enabled, but Pro is unavailable (${proSnapshot.reason}). Falling back to Flash.`,
},
};
}
}
}
+42
View File
@@ -37,6 +37,10 @@ export interface RetryOptions {
signal?: AbortSignal;
getAvailabilityContext?: () => RetryAvailabilityContext | undefined;
onRetry?: (attempt: number, error: unknown, delayMs: number) => void;
timeoutFallback?: {
timeoutMs: number;
fallbackDurationMs: number;
};
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
@@ -240,6 +244,7 @@ export async function retryWithBackoff<T>(
signal,
getAvailabilityContext,
onRetry,
timeoutFallback,
} = {
...DEFAULT_RETRY_OPTIONS,
shouldRetryOnError: isRetryableError,
@@ -248,6 +253,7 @@ export async function retryWithBackoff<T>(
let attempt = 0;
let currentDelay = initialDelayMs;
let startTime = Date.now();
const throwIfAborted = () => {
if (signal?.aborted) {
throw createAbortError();
@@ -294,6 +300,42 @@ export async function retryWithBackoff<T>(
const errorCode = getErrorStatus(error);
const isTimeout =
(error instanceof Error &&
error.message.toLowerCase().includes('timeout')) ||
getRetryErrorType(error) === 'ETIMEDOUT' ||
getRetryErrorType(error) === 'FETCH_FAILED';
if (isTimeout && timeoutFallback) {
if (Date.now() - startTime >= timeoutFallback.timeoutMs) {
const successContext = getAvailabilityContext?.();
if (successContext) {
successContext.service.markTemporarilyUnavailable(
successContext.policy.model,
'timeout',
timeoutFallback.fallbackDurationMs,
);
}
if (onPersistent429) {
try {
const fallbackModel = await onPersistent429(
authType,
new Error('Request timed out'),
);
if (fallbackModel) {
attempt = 0;
currentDelay = initialDelayMs;
startTime = Date.now();
continue;
}
} catch (fallbackError) {
debugLogger.warn('Model fallback failed:', fallbackError);
}
}
throw error;
}
}
if (
classifiedError instanceof TerminalQuotaError ||
classifiedError instanceof ModelNotFoundError