fix(core): dynamic fallback routing for exhausted quota models (#27315)

This commit is contained in:
Coco Sheng
2026-05-20 16:59:33 -04:00
committed by GitHub
parent f79d5e059c
commit 5c4420cc27
8 changed files with 189 additions and 7 deletions
@@ -191,6 +191,7 @@ describe('handleFallback', () => {
expect(policyConfig.getFallbackModelHandler).not.toHaveBeenCalled();
expect(policyConfig.activateFallbackMode).toHaveBeenCalledWith(
DEFAULT_GEMINI_FLASH_MODEL,
undefined,
);
} finally {
chainSpy.mockRestore();
@@ -207,6 +208,9 @@ describe('handleFallback', () => {
selectedModel: MOCK_PRO_MODEL,
skipped: [],
});
// Mock activeModel to be unavailable so the utility bypass heuristic is skipped
vi.mocked(availability.snapshot).mockReturnValue({ available: false });
policyHandler.mockResolvedValue('retry_once');
await handleFallback(
@@ -351,6 +355,8 @@ describe('handleFallback', () => {
vi.mocked(policyConfig.getModel).mockReturnValue(
DEFAULT_GEMINI_MODEL_AUTO,
);
// Mock activeModel to be unavailable so the utility bypass heuristic is skipped
vi.mocked(availability.snapshot).mockReturnValue({ available: false });
const result = await handleFallback(
policyConfig,
@@ -383,6 +389,7 @@ describe('handleFallback', () => {
expect(result).toBe(true);
expect(policyConfig.activateFallbackMode).toHaveBeenCalledWith(
FALLBACK_MODEL,
undefined,
);
// TODO: add logging expect statement
});
+31 -4
View File
@@ -42,8 +42,17 @@ export async function handleFallback(
return { service: availability, policy: failedPolicy };
};
const activeModel = config.getActiveModel();
let fallbackModel: string;
if (!candidates.length) {
if (
failedModel !== activeModel &&
availability.snapshot(activeModel).available
) {
applyAvailabilityTransition(getAvailabilityContext, failureKind);
return processIntent(config, 'retry_always', activeModel, failedModel);
}
fallbackModel = failedModel;
} else {
const selection = availability.selectFirstAvailable(
@@ -70,9 +79,21 @@ export async function handleFallback(
// failureKind is already declared and calculated above
const action = resolvePolicyAction(failureKind, selectedPolicy);
if (action === 'silent') {
if (
action === 'silent' ||
(fallbackModel === activeModel && failedModel !== activeModel)
) {
applyAvailabilityTransition(getAvailabilityContext, failureKind);
return processIntent(config, 'retry_always', fallbackModel);
// For standard auto-routing (silent), we only update the active model, so don't pass failedModel.
// For utility bypass, we want a hard runtime override, so pass failedModel.
const overrideFailedModel =
failedModel !== activeModel ? failedModel : undefined;
return processIntent(
config,
'retry_always',
fallbackModel,
overrideFailedModel,
);
}
// This will be used in the future when FallbackRecommendation is passed through UI
@@ -103,7 +124,12 @@ export async function handleFallback(
applyAvailabilityTransition(getAvailabilityContext, failureKind);
}
return await processIntent(config, intent, fallbackModel);
return await processIntent(
config,
intent,
fallbackModel,
failedModel !== activeModel ? failedModel : undefined,
);
} catch (handlerError) {
debugLogger.error('Fallback handler failed:', handlerError);
return null;
@@ -131,12 +157,13 @@ async function processIntent(
config: Config,
intent: FallbackIntent | null,
fallbackModel: string,
failedModel?: string,
): Promise<boolean> {
switch (intent) {
case 'retry_always':
// TODO(telemetry): Implement generic fallback event logging. Existing
// logFlashFallback is specific to a single Model.
config.activateFallbackMode(fallbackModel);
config.activateFallbackMode(fallbackModel, failedModel);
return true;
case 'retry_once':