Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)

This commit is contained in:
Gaurav
2026-01-20 16:23:01 -08:00
committed by GitHub
parent aceb06a587
commit 3b626e7c61
18 changed files with 1060 additions and 12 deletions
+1
View File
@@ -166,6 +166,7 @@ const mockUIActions: UIActions = {
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
handleValidationChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
+11 -1
View File
@@ -495,7 +495,12 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [authState, authContext, setAuthState]);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
const {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
} = useQuotaAndFallback({
config,
historyManager,
userTier,
@@ -1471,6 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showPrivacyNotice ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
@@ -1588,6 +1594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1678,6 +1685,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showAutoAcceptIndicator,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1747,6 +1755,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -1787,6 +1796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -17,6 +17,7 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
@@ -68,6 +69,16 @@ export const DialogManager = ({
/>
);
}
if (uiState.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.validationRequest.validationLink}
validationDescription={uiState.validationRequest.validationDescription}
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { act } from 'react';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { ValidationDialog } from './ValidationDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
// Mock the child components and utilities
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(),
}));
vi.mock('./CliSpinner.js', () => ({
CliSpinner: vi.fn(() => null),
}));
const mockOpenBrowserSecurely = vi.fn();
const mockShouldLaunchBrowser = vi.fn();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
openBrowserSecurely: (...args: unknown[]) =>
mockOpenBrowserSecurely(...args),
shouldLaunchBrowser: () => mockShouldLaunchBrowser(),
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('ValidationDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockShouldLaunchBrowser.mockReturnValue(true);
mockOpenBrowserSecurely.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial render (choosing state)', () => {
it('should render the main message and two options', () => {
const { lastFrame, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
expect(lastFrame()).toContain(
'Further action is required to use this service.',
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Verify your account',
value: 'verify',
key: 'verify',
},
{
label: 'Change authentication',
value: 'change_auth',
key: 'change_auth',
},
],
}),
undefined,
);
unmount();
});
it('should render learn more URL when provided', () => {
const { lastFrame, unmount } = render(
<ValidationDialog
learnMoreUrl="https://example.com/help"
onChoice={mockOnChoice}
/>,
);
expect(lastFrame()).toContain('Learn more:');
expect(lastFrame()).toContain('https://example.com/help');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onChoice with change_auth when that option is selected', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('change_auth');
});
expect(mockOnChoice).toHaveBeenCalledWith('change_auth');
unmount();
});
it('should call onChoice with verify when no validation link is provided', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('verify');
});
expect(mockOnChoice).toHaveBeenCalledWith('verify');
unmount();
});
it('should open browser and transition to waiting state when verify is selected with a link', async () => {
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://accounts.google.com/verify',
);
expect(lastFrame()).toContain('Waiting for verification...');
unmount();
});
});
describe('headless mode', () => {
it('should show URL in message when browser cannot be launched', async () => {
mockShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Please open this URL in a browser:');
expect(lastFrame()).toContain('https://accounts.google.com/verify');
unmount();
});
});
describe('error state', () => {
it('should show error and options when browser fails to open', async () => {
mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(lastFrame()).toContain('Browser not found');
// RadioButtonSelect should be rendered again with options in error state
expect((RadioButtonSelect as Mock).mock.calls.length).toBeGreaterThan(1);
unmount();
});
});
});
@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { CliSpinner } from './CliSpinner.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
type ValidationIntent,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
interface ValidationDialogProps {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
onChoice: (choice: ValidationIntent) => void;
}
type DialogState = 'choosing' | 'waiting' | 'complete' | 'error';
export function ValidationDialog({
validationLink,
learnMoreUrl,
onChoice,
}: ValidationDialogProps): React.JSX.Element {
const [state, setState] = useState<DialogState>('choosing');
const [errorMessage, setErrorMessage] = useState<string>('');
const items = [
{
label: 'Verify your account',
value: 'verify' as const,
key: 'verify',
},
{
label: 'Change authentication',
value: 'change_auth' as const,
key: 'change_auth',
},
];
// Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion)
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
onChoice('cancel');
} else if (keyMatchers[Command.RETURN](key)) {
// User confirmed verification is complete - transition to 'complete' state
setState('complete');
}
},
{ isActive: state === 'waiting' },
);
// When state becomes 'complete', show success message briefly then proceed
useEffect(() => {
if (state === 'complete') {
const timer = setTimeout(() => {
onChoice('verify');
}, 500);
return () => clearTimeout(timer);
}
return undefined;
}, [state, onChoice]);
const handleSelect = useCallback(
async (choice: ValidationIntent) => {
if (choice === 'verify') {
if (validationLink) {
// Check if we're in an environment where we can launch a browser
if (!shouldLaunchBrowser()) {
// In headless mode, show the link and wait for user to manually verify
setErrorMessage(
`Please open this URL in a browser: ${validationLink}`,
);
setState('waiting');
return;
}
try {
await openBrowserSecurely(validationLink);
setState('waiting');
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : 'Failed to open browser',
);
setState('error');
}
} else {
// No validation link, just retry
onChoice('verify');
}
} else {
// 'change_auth' or 'cancel'
onChoice(choice);
}
},
[validationLink, onChoice],
);
if (state === 'error') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Text color={theme.status.error}>
{errorMessage ||
'Failed to open verification link. Please try again or change authentication.'}
</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
/>
</Box>
</Box>
);
}
if (state === 'waiting') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box>
<CliSpinner />
<Text>
{' '}
Waiting for verification... (Press ESC or CTRL+C to cancel)
</Text>
</Box>
{errorMessage && (
<Box marginTop={1}>
<Text>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>Press Enter when verification is complete.</Text>
</Box>
</Box>
);
}
if (state === 'complete') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Text color={theme.status.success}>Verification complete</Text>
</Box>
);
}
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text>Further action is required to use this service.</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
/>
</Box>
{learnMoreUrl && (
<Box marginTop={1}>
<Text dimColor>
Learn more: <Text color={theme.text.accent}>{learnMoreUrl}</Text>
</Text>
</Box>
)}
</Box>
);
}
@@ -46,6 +46,7 @@ export interface UIActions {
handleProQuotaChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void;
openSessionBrowser: () => void;
closeSessionBrowser: () => void;
handleResumeSession: (session: SessionInfo) => Promise<void>;
@@ -23,6 +23,7 @@ import type {
UserTierId,
IdeInfo,
FallbackIntent,
ValidationIntent,
} from '@google/gemini-cli-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -38,6 +39,13 @@ export interface ProQuotaDialogRequest {
resolve: (intent: FallbackIntent) => void;
}
export interface ValidationDialogRequest {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
resolve: (intent: ValidationIntent) => void;
}
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
@@ -102,6 +110,7 @@ export interface UIState {
// Quota-related state
userTier: UserTierId | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
currentModel: string;
contextFileNames: string[];
errorCount: number;
@@ -28,6 +28,7 @@ import {
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
ValidationRequiredError,
coreEvents,
CoreEvent,
MCPDiscoveryState,
@@ -1100,6 +1101,12 @@ export const useGeminiStream = (
spanMetadata.error = error;
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (
// Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
error instanceof ValidationRequiredError &&
error.userHandled
) {
// Error was handled by validation dialog, don't display again
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
@@ -498,4 +498,186 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
);
});
});
describe('Validation Handler', () => {
let setValidationHandlerSpy: SpyInstance;
beforeEach(() => {
setValidationHandlerSpy = vi.spyOn(mockConfig, 'setValidationHandler');
});
it('should register a validation handler on initialization', () => {
renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
expect(setValidationHandlerSpy).toHaveBeenCalledTimes(1);
expect(setValidationHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
});
it('should set a validation request when handler is called', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler(
'https://example.com/verify',
'Please verify',
'https://example.com/help',
);
});
const request = result.current.validationRequest;
expect(request).not.toBeNull();
expect(request?.validationLink).toBe('https://example.com/verify');
expect(request?.validationDescription).toBe('Please verify');
expect(request?.learnMoreUrl).toBe('https://example.com/help');
// Simulate user choosing verify
act(() => {
result.current.handleValidationChoice('verify');
});
const intent = await promise!;
expect(intent).toBe('verify');
expect(result.current.validationRequest).toBeNull();
});
it('should handle race conditions by returning cancel for subsequent requests', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise1: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise1 = handler('https://example.com/verify1');
});
const firstRequest = result.current.validationRequest;
expect(firstRequest).not.toBeNull();
let result2: 'verify' | 'change_auth' | 'cancel';
await act(async () => {
result2 = await handler('https://example.com/verify2');
});
// The lock should have stopped the second request
expect(result2!).toBe('cancel');
expect(result.current.validationRequest).toBe(firstRequest);
// Complete the first request
act(() => {
result.current.handleValidationChoice('verify');
});
const intent1 = await promise1!;
expect(intent1).toBe('verify');
expect(result.current.validationRequest).toBeNull();
});
it('should add info message when change_auth is chosen', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler('https://example.com/verify');
});
act(() => {
result.current.handleValidationChoice('change_auth');
});
const intent = await promise!;
expect(intent).toBe('change_auth');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toBe('Use /auth to change authentication method.');
});
it('should not add info message when cancel is chosen', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler('https://example.com/verify');
});
act(() => {
result.current.handleValidationChoice('cancel');
});
const intent = await promise!;
expect(intent).toBe('cancel');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
it('should do nothing if handleValidationChoice is called without pending request', () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
act(() => {
result.current.handleValidationChoice('verify');
});
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
});
});
@@ -9,6 +9,8 @@ import {
type Config,
type FallbackModelHandler,
type FallbackIntent,
type ValidationHandler,
type ValidationIntent,
TerminalQuotaError,
ModelNotFoundError,
type UserTierId,
@@ -19,7 +21,10 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType } from '../types.js';
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
import {
type ProQuotaDialogRequest,
type ValidationDialogRequest,
} from '../contexts/UIStateContext.js';
interface UseQuotaAndFallbackArgs {
config: Config;
@@ -36,7 +41,10 @@ export function useQuotaAndFallback({
}: UseQuotaAndFallbackArgs) {
const [proQuotaRequest, setProQuotaRequest] =
useState<ProQuotaDialogRequest | null>(null);
const [validationRequest, setValidationRequest] =
useState<ValidationDialogRequest | null>(null);
const isDialogPending = useRef(false);
const isValidationPending = useRef(false);
// Set up Flash fallback handler
useEffect(() => {
@@ -120,6 +128,36 @@ export function useQuotaAndFallback({
config.setFallbackModelHandler(fallbackHandler);
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
// Set up validation handler for 403 VALIDATION_REQUIRED errors
useEffect(() => {
const validationHandler: ValidationHandler = async (
validationLink,
validationDescription,
learnMoreUrl,
): Promise<ValidationIntent> => {
if (isValidationPending.current) {
return 'cancel'; // A validation dialog is already active
}
isValidationPending.current = true;
const intent: ValidationIntent = await new Promise<ValidationIntent>(
(resolve) => {
// Call setValidationRequest directly - same pattern as proQuotaRequest
setValidationRequest({
validationLink,
validationDescription,
learnMoreUrl,
resolve,
});
},
);
return intent;
};
config.setValidationHandler(validationHandler);
}, [config]);
const handleProQuotaChoice = useCallback(
(choice: FallbackIntent) => {
if (!proQuotaRequest) return;
@@ -148,9 +186,35 @@ export function useQuotaAndFallback({
[proQuotaRequest, historyManager, config, setModelSwitchedFromQuotaError],
);
const handleValidationChoice = useCallback(
(choice: ValidationIntent) => {
// Guard against double-execution (e.g. rapid clicks) and stale requests
if (!isValidationPending.current || !validationRequest) return;
// Immediately clear the flag to prevent any subsequent calls from passing the guard
isValidationPending.current = false;
validationRequest.resolve(choice);
setValidationRequest(null);
if (choice === 'change_auth') {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Use /auth to change authentication method.',
},
Date.now(),
);
}
},
[validationRequest, historyManager],
);
return {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
};
}
+13 -1
View File
@@ -67,7 +67,10 @@ import {
ApprovalModeSwitchEvent,
ApprovalModeDurationEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import type {
FallbackModelHandler,
ValidationHandler,
} from '../fallback/types.js';
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
@@ -477,6 +480,7 @@ export class Config {
private readonly _enabledExtensions: string[];
private readonly enableExtensionReloading: boolean;
fallbackModelHandler?: FallbackModelHandler;
validationHandler?: ValidationHandler;
private quotaErrorOccurred: boolean = false;
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
@@ -1066,6 +1070,14 @@ export class Config {
return this.fallbackModelHandler;
}
setValidationHandler(handler: ValidationHandler): void {
this.validationHandler = handler;
}
getValidationHandler(): ValidationHandler | undefined {
return this.validationHandler;
}
resetTurn(): void {
this.modelAvailabilityService.resetTurn();
}
+22
View File
@@ -25,6 +25,7 @@ import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
import { retryWithBackoff } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { getErrorMessage } from '../utils/errors.js';
import { tokenLimit } from './tokenLimits.js';
import type {
@@ -926,8 +927,29 @@ export class GeminiClient {
// Pass the captured model to the centralized handler.
handleFallback(this.config, currentAttemptModel, authType, error);
const onValidationRequiredCallback = async (
validationError: ValidationRequiredError,
) => {
// Suppress validation dialog for background calls (e.g. prompt-completion)
// to prevent the dialog from appearing on startup or during typing.
if (modelConfigKey.model === 'prompt-completion') {
throw validationError;
}
const handler = this.config.getValidationHandler();
if (typeof handler !== 'function') {
throw validationError;
}
return handler(
validationError.validationLink,
validationError.validationDescription,
validationError.learnMoreUrl,
);
};
const result = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
maxAttempts: availabilityMaxAttempts,
getAvailabilityContext,
+17
View File
@@ -19,6 +19,7 @@ import type {
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
resolveModel,
@@ -579,8 +580,24 @@ export class GeminiChat {
error?: unknown,
) => handleFallback(this.config, lastModelToUse, authType, error);
const onValidationRequiredCallback = async (
validationError: ValidationRequiredError,
) => {
const handler = this.config.getValidationHandler();
if (typeof handler !== 'function') {
// No handler registered, re-throw to show default error message
throw validationError;
}
return handler(
validationError.validationLink,
validationError.validationDescription,
validationError.learnMoreUrl,
);
};
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: abortSignal,
+18
View File
@@ -37,3 +37,21 @@ export type FallbackModelHandler = (
fallbackModel: string,
error?: unknown,
) => Promise<FallbackIntent | null>;
/**
* Defines the intent returned by the UI layer during a validation required scenario.
*/
export type ValidationIntent =
| 'verify' // User chose to verify, wait for completion then retry.
| 'change_auth' // User chose to change authentication method.
| 'cancel'; // User cancelled the verification process.
/**
* The interface for the handler provided by the UI layer (e.g., the CLI)
* to interact with the user when validation is required.
*/
export type ValidationHandler = (
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) => Promise<ValidationIntent>;
+1
View File
@@ -90,6 +90,7 @@ export * from './utils/extensionLoader.js';
export * from './utils/package.js';
export * from './utils/version.js';
export * from './utils/checkpointUtils.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
@@ -9,6 +9,7 @@ import {
classifyGoogleError,
RetryableQuotaError,
TerminalQuotaError,
ValidationRequiredError,
} from './googleQuotaErrors.js';
import * as errorParser from './googleErrors.js';
import type { GoogleApiError } from './googleErrors.js';
@@ -449,4 +450,190 @@ describe('classifyGoogleError', () => {
expect(result.retryDelayMs).toBeUndefined();
}
});
it('should return ValidationRequiredError for 403 with VALIDATION_REQUIRED from cloudcode-pa domain', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required to continue.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {
validation_link: 'https://fallback.example.com/validate',
},
},
{
'@type': 'type.googleapis.com/google.rpc.Help',
links: [
{
description: 'Complete validation to continue',
url: 'https://example.com/validate',
},
{
description: 'Learn more',
url: 'https://support.google.com/accounts?p=al_alert',
},
],
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
expect((result as ValidationRequiredError).validationLink).toBe(
'https://example.com/validate',
);
expect((result as ValidationRequiredError).validationDescription).toBe(
'Complete validation to continue',
);
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
'https://support.google.com/accounts?p=al_alert',
);
expect((result as ValidationRequiredError).cause).toBe(apiError);
});
it('should correctly parse Learn more URL when first link description contains "Learn more" text', () => {
// This tests the real API response format where the description of the first
// link contains "Learn more:" text, but we should use the second link's URL
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required to continue.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {},
},
{
'@type': 'type.googleapis.com/google.rpc.Help',
links: [
{
description:
'Further action is required to use this service. Navigate to the following URL to complete verification:\n\nhttps://accounts.sandbox.google.com/signin/continue?...\n\nLearn more:\n\nhttps://support.google.com/accounts?p=al_alert\n',
url: 'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...',
},
{
description: 'Learn more',
url: 'https://support.google.com/accounts?p=al_alert',
},
],
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
// Should get the validation link from the first link
expect((result as ValidationRequiredError).validationLink).toBe(
'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...',
);
// Should get the Learn more URL from the SECOND link, not the first
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
'https://support.google.com/accounts?p=al_alert',
);
});
it('should fallback to ErrorInfo metadata when Help detail is not present', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'staging-cloudcode-pa.googleapis.com',
metadata: {
validation_link: 'https://staging.example.com/validate',
},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
expect((result as ValidationRequiredError).validationLink).toBe(
'https://staging.example.com/validate',
);
expect(
(result as ValidationRequiredError).validationDescription,
).toBeUndefined();
expect((result as ValidationRequiredError).learnMoreUrl).toBeUndefined();
});
it('should return original error for 403 with different reason', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Access denied.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'ACCESS_DENIED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const originalError = new Error();
const result = classifyGoogleError(originalError);
expect(result).toBe(originalError);
expect(result).not.toBeInstanceOf(ValidationRequiredError);
});
it('should find learn more link by hostname when description is different', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {},
},
{
'@type': 'type.googleapis.com/google.rpc.Help',
links: [
{
description: 'Complete validation',
url: 'https://accounts.google.com/validate',
},
{
description: 'More information', // Not exactly "Learn more"
url: 'https://support.google.com/accounts?p=al_alert',
},
],
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
'https://support.google.com/accounts?p=al_alert',
);
});
it('should return original error for 403 from non-cloudcode domain', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Forbidden.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'other.googleapis.com',
metadata: {},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const originalError = new Error();
const result = classifyGoogleError(originalError);
expect(result).toBe(originalError);
expect(result).not.toBeInstanceOf(ValidationRequiredError);
});
});
+118 -9
View File
@@ -7,6 +7,7 @@
import type {
ErrorInfo,
GoogleApiError,
Help,
QuotaFailure,
RetryInfo,
} from './googleErrors.js';
@@ -51,6 +52,30 @@ export class RetryableQuotaError extends Error {
}
}
/**
* An error indicating that user validation is required to continue.
*/
export class ValidationRequiredError extends Error {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
userHandled: boolean = false;
constructor(
message: string,
override readonly cause: GoogleApiError,
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) {
super(message);
this.name = 'ValidationRequiredError';
this.validationLink = validationLink;
this.validationDescription = validationDescription;
this.learnMoreUrl = learnMoreUrl;
}
}
/**
* Parses a duration string (e.g., "34.074824224s", "60s", "900ms") and returns the time in seconds.
* @param duration The duration string to parse.
@@ -69,18 +94,94 @@ function parseDurationInSeconds(duration: string): number | null {
}
/**
* Analyzes a caught error and classifies it as a specific quota-related error if applicable.
* Valid Cloud Code API domains for VALIDATION_REQUIRED errors.
*/
const CLOUDCODE_DOMAINS = [
'cloudcode-pa.googleapis.com',
'staging-cloudcode-pa.googleapis.com',
'autopush-cloudcode-pa.googleapis.com',
];
/**
* Checks if a 403 error requires user validation and extracts validation details.
*
* It decides whether an error is a `TerminalQuotaError` or a `RetryableQuotaError` based on
* the following logic:
* - If the error indicates a daily limit, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`.
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
* @param googleApiError The parsed Google API error to check.
* @returns A `ValidationRequiredError` if validation is required, otherwise `null`.
*/
function classifyValidationRequiredError(
googleApiError: GoogleApiError,
): ValidationRequiredError | null {
const errorInfo = googleApiError.details.find(
(d): d is ErrorInfo =>
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
);
if (!errorInfo) {
return null;
}
if (
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
errorInfo.reason !== 'VALIDATION_REQUIRED'
) {
return null;
}
// Try to extract validation info from Help detail first
const helpDetail = googleApiError.details.find(
(d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help',
);
let validationLink: string | undefined;
let validationDescription: string | undefined;
let learnMoreUrl: string | undefined;
if (helpDetail?.links && helpDetail.links.length > 0) {
// First link is the validation link, extract description and URL
const validationLinkInfo = helpDetail.links[0];
validationLink = validationLinkInfo.url;
validationDescription = validationLinkInfo.description;
// Look for "Learn more" link - identified by description or support.google.com hostname
const learnMoreLink = helpDetail.links.find((link) => {
if (link.description.toLowerCase().trim() === 'learn more') return true;
const parsed = URL.parse(link.url);
return parsed?.hostname === 'support.google.com';
});
if (learnMoreLink) {
learnMoreUrl = learnMoreLink.url;
}
}
// Fallback to ErrorInfo metadata if Help detail not found
if (!validationLink) {
validationLink = errorInfo.metadata?.['validation_link'];
}
return new ValidationRequiredError(
googleApiError.message,
googleApiError,
validationLink,
validationDescription,
learnMoreUrl,
);
}
/**
* Analyzes a caught error and classifies it as a specific error type if applicable.
*
* Classification logic:
* - 404 errors are classified as `ModelNotFoundError`.
* - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified
* as `ValidationRequiredError`.
* - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
* - If the error indicates a daily limit, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`.
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
*
* @param error The error to classify.
* @returns A `TerminalQuotaError`, `RetryableQuotaError`, or the original `unknown` error.
* @returns A classified error or the original `unknown` error.
*/
export function classifyGoogleError(error: unknown): unknown {
const googleApiError = parseGoogleApiError(error);
@@ -93,6 +194,14 @@ export function classifyGoogleError(error: unknown): unknown {
return new ModelNotFoundError(message, status);
}
// Check for 403 VALIDATION_REQUIRED errors from Cloud Code API
if (status === 403 && googleApiError) {
const validationError = classifyValidationRequiredError(googleApiError);
if (validationError) {
return validationError;
}
}
if (
!googleApiError ||
googleApiError.code !== 429 ||
+25
View File
@@ -9,6 +9,7 @@ import { ApiError } from '@google/genai';
import {
TerminalQuotaError,
RetryableQuotaError,
ValidationRequiredError,
classifyGoogleError,
} from './googleQuotaErrors.js';
import { delay, createAbortError } from './delay.js';
@@ -28,6 +29,9 @@ export interface RetryOptions {
authType?: string,
error?: unknown,
) => Promise<string | boolean | null>;
onValidationRequired?: (
error: ValidationRequiredError,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
authType?: string;
retryFetchErrors?: boolean;
signal?: AbortSignal;
@@ -144,6 +148,7 @@ export async function retryWithBackoff<T>(
initialDelayMs,
maxDelayMs,
onPersistent429,
onValidationRequired,
authType,
shouldRetryOnError,
shouldRetryOnContent,
@@ -220,6 +225,26 @@ export async function retryWithBackoff<T>(
throw classifiedError; // Throw if no fallback or fallback failed.
}
// Handle ValidationRequiredError - user needs to verify before proceeding
if (classifiedError instanceof ValidationRequiredError) {
if (onValidationRequired) {
try {
const intent = await onValidationRequired(classifiedError);
if (intent === 'verify') {
// User verified, retry the request
attempt = 0;
currentDelay = initialDelayMs;
continue;
}
// 'change_auth' or 'cancel' - mark as handled and throw
classifiedError.userHandled = true;
} catch (validationError) {
debugLogger.warn('Validation handler failed:', validationError);
}
}
throw classifiedError;
}
const is500 =
errorCode !== undefined && errorCode >= 500 && errorCode < 600;