Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/DialogManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const DialogManager = ({
message={quotaState.proQuotaRequest.message}
isTerminalQuotaError={quotaState.proQuotaRequest.isTerminalQuotaError}
isModelNotFoundError={!!quotaState.proQuotaRequest.isModelNotFoundError}
isCapacityExceeded={!!quotaState.proQuotaRequest.isCapacityExceeded}
authType={quotaState.proQuotaRequest.authType}
tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice}
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/ui/components/ProQuotaDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,40 @@ describe('ProQuotaDialog', () => {
);
unmount();
});

it('should render keep trying, switch, and stop options even if isTerminalQuotaError is true when isCapacityExceeded is true', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="capacity error"
isTerminalQuotaError={true}
isCapacityExceeded={true}
isModelNotFoundError={false}
onChoice={mockOnChoice}
/>,
);

expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Keep trying',
value: 'retry_once',
key: 'retry_once',
},
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{ label: 'Stop', value: 'retry_later', key: 'retry_later' },
],
}),
undefined,
);
unmount();
});
});

describe('when it is a model not found error', () => {
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/ui/components/ProQuotaDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface ProQuotaDialogProps {
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
isCapacityExceeded?: boolean;
authType?: AuthType;
tierName?: string;
onChoice: (
Expand All @@ -30,6 +31,7 @@ export function ProQuotaDialog({
message,
isTerminalQuotaError,
isModelNotFoundError,
isCapacityExceeded,
authType,
tierName,
onChoice,
Expand All @@ -49,6 +51,24 @@ export function ProQuotaDialog({
key: 'retry_later',
},
];
} else if (isCapacityExceeded) {
items = [
{
label: 'Keep trying',
value: 'retry_once' as const,
key: 'retry_once',
},
{
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later' as const,
key: 'retry_later',
},
];
} else if (isModelNotFoundError || isTerminalQuotaError) {
const isUltra = isUltraTier(tierName);

Expand All @@ -75,7 +95,7 @@ export function ProQuotaDialog({
},
];
} else {
// capacity error
// capacity error or generic fallback
items = [
{
label: 'Keep trying',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/contexts/UIStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface ProQuotaDialogRequest {
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
isCapacityExceeded?: boolean;
authType?: AuthType;
resolve: (intent: FallbackIntent) => void;
}
Expand Down
122 changes: 121 additions & 1 deletion packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,126 @@
await promise!;
});

it('should auto-retry terminal quota capacity failures in low verbosity mode', async () => {
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);

const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const intent = await handler(
'gemini-pro',
'gemini-flash',
new TerminalQuotaError(
'pro capacity exhausted',
mockGoogleApiError,
undefined,
'MODEL_CAPACITY_EXHAUSTED',
),
);

expect(intent).toBe('retry_once');
expect(result.current.proQuotaRequest).toBeNull();
});

it('should auto-retry capacity failures matched by regex on message in low verbosity mode', async () => {
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);

const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const intent = await handler(
'gemini-pro',
'gemini-flash',
new Error('you have exhausted your capacity limit'),
);

expect(intent).toBe('retry_once');
expect(result.current.proQuotaRequest).toBeNull();
});

it('should auto-retry capacity failures thrown as raw string error in low verbosity mode', async () => {
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);

const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const intent = await handler(
'gemini-pro',
'gemini-flash',
'MODEL_CAPACITY_EXHAUSTED',
);

expect(intent).toBe('retry_once');
expect(result.current.proQuotaRequest).toBeNull();
});

it('should show high demand message for MODEL_CAPACITY_EXHAUSTED', async () => {
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);

const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;

const error = new TerminalQuotaError(
'pro capacity exhausted',
mockGoogleApiError,
undefined,
'MODEL_CAPACITY_EXHAUSTED',
);

act(() => {
void handler('gemini-pro', 'gemini-flash', error);
});

expect(result.current.proQuotaRequest).not.toBeNull();
expect(result.current.proQuotaRequest?.isCapacityExceeded).toBe(true);
expect(result.current.proQuotaRequest?.message).toContain(
'We are currently experiencing high demand',
);
expect(result.current.proQuotaRequest?.message).not.toContain(
'Usage limit reached',
);
});

describe('Interactive Fallback', () => {
it('should set an interactive request for a terminal quota error', async () => {
const { result } = await renderHook(() =>
Expand Down Expand Up @@ -550,17 +670,17 @@
const error = new ModelNotFoundError('model not found', 404);

act(() => {
promise = handler('gemini-3.5-flash', 'gemini-1.5-flash', error);

Check warning on line 673 in packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
});

const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-3.5-flash');

Check warning on line 678 in packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
expect(request?.isModelNotFoundError).toBe(true);

const message = request!.message;
expect(message).toBe(
`Model "gemini-3.5-flash" is not available in region "us-central1".\n` +

Check warning on line 683 in packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
`To see which models are available in this region, please visit:\n` +
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations\n` +
`/model to switch models.`,
Expand Down Expand Up @@ -1040,7 +1160,7 @@
);
});

it('should show a special message when falling back from the preview model, but do not show periodical check message for flash model fallback', async () => {
it('should show a special message when falling back from the preview model, but not show the periodical check message for flash model fallbacks', async () => {
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
Expand Down
64 changes: 51 additions & 13 deletions packages/cli/src/ui/hooks/useQuotaAndFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ interface UseQuotaAndFallbackArgs {
errorVerbosity?: 'low' | 'full';
}

const isObject = (val: unknown): val is Record<string, unknown> =>
typeof val === 'object' && val !== null;

export function useQuotaAndFallback({
config,
historyManager,
Expand Down Expand Up @@ -79,6 +82,28 @@ export function useQuotaAndFallback({
let message: string;
let isTerminalQuotaError = false;
let isModelNotFoundError = false;

const errorObj = isObject(error) ? error : null;

const errorReasonValue = errorObj?.['reason'];
const errorReason =
typeof errorReasonValue === 'string' ? errorReasonValue : undefined;

const errorMessageValue = errorObj?.['message'];
const errorMessage =
typeof errorMessageValue === 'string' ? errorMessageValue : undefined;

const isCapacityExceeded =
errorReason === 'MODEL_CAPACITY_EXHAUSTED' ||
errorReason === 'MODEL_CAPACITY_EXCEEDED' ||
(typeof errorMessage === 'string' &&
/exhausted your capacity|capacity exceeded|MODEL_CAPACITY_EXHAUSTED/i.test(
errorMessage,
)) ||
(typeof error === 'string' &&
/exhausted your capacity|capacity exceeded|MODEL_CAPACITY_EXHAUSTED/i.test(
error,
));
const usageLimitReachedModel = isProModel(failedModel)
? 'all Pro models'
: failedModel;
Expand Down Expand Up @@ -121,18 +146,30 @@ export function useQuotaAndFallback({
}

// Default: Show existing ProQuotaDialog (for overageStrategy: 'never' or non-G1 users)
const messageLines = [
`Usage limit reached for ${usageLimitReachedModel}.`,
error.retryDelayMs
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
: null,
`/stats model for usage details`,
`/model to switch models.`,
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
? `/auth to switch to API key.`
: null,
].filter(Boolean);
message = messageLines.join('\n');
if (isCapacityExceeded) {
const messageLines = [
`We are currently experiencing high demand for ${usageLimitReachedModel}.`,
'We apologize and appreciate your patience.',
error.retryDelayMs
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
: null,
`/model to switch models.`,
].filter(Boolean);
message = messageLines.join('\n');
} else {
const messageLines = [
`Usage limit reached for ${usageLimitReachedModel}.`,
error.retryDelayMs
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
: null,
`/stats model for usage details`,
`/model to switch models.`,
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
? `/auth to switch to API key.`
: null,
].filter(Boolean);
message = messageLines.join('\n');
}
} else if (error instanceof ModelNotFoundError) {
isModelNotFoundError = true;
if (
Expand Down Expand Up @@ -174,7 +211,7 @@ export function useQuotaAndFallback({
// without interrupting with a dialog.
if (
errorVerbosity === 'low' &&
!isTerminalQuotaError &&
(!isTerminalQuotaError || isCapacityExceeded) &&
!isModelNotFoundError
) {
return 'retry_once';
Expand All @@ -197,6 +234,7 @@ export function useQuotaAndFallback({
message,
isTerminalQuotaError,
isModelNotFoundError,
isCapacityExceeded,
authType: contentGeneratorConfig?.authType,
});
},
Expand Down
26 changes: 24 additions & 2 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3198,6 +3198,24 @@
expect(config.getHasAccessToPreviewModel()).toBe(false);
});

it('should reverse-map gemini-3-flash back to gemini-3.5-flash in modelQuotas', async () => {

Check warning on line 3201 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-flash',
remainingAmount: '90',
remainingFraction: 0.9,
},
],
});

config.setModel('gemini-3.5-flash');

Check warning on line 3212 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
await config.refreshUserQuota();

expect(config.getQuotaRemaining()).toBe(90);
expect(config.getQuotaLimit()).toBe(100);
});

it('should calculate pooled quota correctly for auto models', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
Expand Down Expand Up @@ -4134,7 +4152,9 @@

const plansDir = config.storage.getPlansDir();
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);

Expand All @@ -4152,7 +4172,9 @@
await config.initialize();

const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);

const context = config.getWorkspaceContext();
Expand Down Expand Up @@ -4357,7 +4379,7 @@
cwd: '.',
};

it('should set DEFAULT_GEMINI_FLASH_MODEL to gemini-3.5-flash and PREVIEW_GEMINI_FLASH_MODEL to gemini-3-flash-preview if hasGemini35FlashGAAccess returns true and authType is USE_GEMINI', () => {

Check warning on line 4382 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.USE_GEMINI };

Expand All @@ -4375,11 +4397,11 @@
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);

expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');

Check warning on line 4400 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash-preview');
});

it('should set DEFAULT_GEMINI_FLASH_MODEL and PREVIEW_GEMINI_FLASH_MODEL to gemini-3.5-flash if hasGemini35FlashGAAccess returns true and authType is not USE_GEMINI', () => {

Check warning on line 4404 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.LOGIN_WITH_GOOGLE };

Expand All @@ -4397,7 +4419,7 @@
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);

expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');

Check warning on line 4422 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');

Check warning on line 4423 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
});
});
Loading
Loading