mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
merge origin/main and resolve regressions
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { GeminiUserTier } from '../code_assist/types.js';
|
||||
import {
|
||||
buildG1Url,
|
||||
getG1CreditBalance,
|
||||
G1_CREDIT_TYPE,
|
||||
G1_UTM_CAMPAIGNS,
|
||||
isOverageEligibleModel,
|
||||
shouldAutoUseCredits,
|
||||
shouldShowEmptyWalletMenu,
|
||||
shouldShowOverageMenu,
|
||||
wrapInAccountChooser,
|
||||
} from './billing.js';
|
||||
|
||||
describe('billing', () => {
|
||||
describe('wrapInAccountChooser', () => {
|
||||
it('should wrap URL with AccountChooser redirect', () => {
|
||||
const result = wrapInAccountChooser(
|
||||
'user@gmail.com',
|
||||
'https://one.google.com/ai/activity',
|
||||
);
|
||||
expect(result).toBe(
|
||||
'https://accounts.google.com/AccountChooser?Email=user%40gmail.com&continue=https%3A%2F%2Fone.google.com%2Fai%2Factivity',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in email', () => {
|
||||
const result = wrapInAccountChooser(
|
||||
'user+test@example.com',
|
||||
'https://example.com',
|
||||
);
|
||||
expect(result).toContain('Email=user%2Btest%40example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildG1Url', () => {
|
||||
it('should build activity URL with UTM params wrapped in AccountChooser', () => {
|
||||
const result = buildG1Url(
|
||||
'activity',
|
||||
'user@gmail.com',
|
||||
G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY,
|
||||
);
|
||||
|
||||
// Should contain AccountChooser prefix
|
||||
expect(result).toContain('https://accounts.google.com/AccountChooser');
|
||||
expect(result).toContain('Email=user%40gmail.com');
|
||||
|
||||
// The continue URL should contain the G1 activity path and UTM params
|
||||
expect(result).toContain('one.google.com%2Fai%2Factivity');
|
||||
expect(result).toContain('utm_source%3Dgemini_cli');
|
||||
expect(result).toContain(
|
||||
'utm_campaign%3Dhydrogen_cli_settings_ai_credits_activity_page',
|
||||
);
|
||||
});
|
||||
|
||||
it('should build credits URL with UTM params wrapped in AccountChooser', () => {
|
||||
const result = buildG1Url(
|
||||
'credits',
|
||||
'test@example.com',
|
||||
G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS,
|
||||
);
|
||||
|
||||
expect(result).toContain('https://accounts.google.com/AccountChooser');
|
||||
expect(result).toContain('one.google.com%2Fai%2Fcredits');
|
||||
expect(result).toContain(
|
||||
'utm_campaign%3Dhydrogen_cli_insufficient_credits_add_credits',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getG1CreditBalance', () => {
|
||||
it('should return null for null tier', () => {
|
||||
expect(getG1CreditBalance(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined tier', () => {
|
||||
expect(getG1CreditBalance(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for tier without availableCredits', () => {
|
||||
const tier: GeminiUserTier = { id: 'PERSONAL' };
|
||||
expect(getG1CreditBalance(tier)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty availableCredits array', () => {
|
||||
const tier: GeminiUserTier = { id: 'PERSONAL', availableCredits: [] };
|
||||
expect(getG1CreditBalance(tier)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no G1 credit type found', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: 'CREDIT_TYPE_UNSPECIFIED', creditAmount: '100' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return G1 credit balance when present', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [{ creditType: G1_CREDIT_TYPE, creditAmount: '500' }],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(500);
|
||||
});
|
||||
|
||||
it('should return G1 credit balance when multiple credit types present', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: 'CREDIT_TYPE_UNSPECIFIED', creditAmount: '100' },
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '750' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(750);
|
||||
});
|
||||
|
||||
it('should return 0 for invalid credit amount', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: 'invalid' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large credit amounts (int64 as string)', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '9999999999' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(9999999999);
|
||||
});
|
||||
|
||||
it('should sum multiple credits of the same G1 type', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '1000' },
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '8' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(1008);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAutoUseCredits', () => {
|
||||
it('should return true when strategy is always and balance > 0', () => {
|
||||
expect(shouldAutoUseCredits('always', 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when strategy is always but balance is 0', () => {
|
||||
expect(shouldAutoUseCredits('always', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is ask', () => {
|
||||
expect(shouldAutoUseCredits('ask', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is never', () => {
|
||||
expect(shouldAutoUseCredits('never', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when creditBalance is null (ineligible)', () => {
|
||||
expect(shouldAutoUseCredits('always', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowOverageMenu', () => {
|
||||
it('should return true when strategy is ask and balance > 0', () => {
|
||||
expect(shouldShowOverageMenu('ask', 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when strategy is ask but balance is 0', () => {
|
||||
expect(shouldShowOverageMenu('ask', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is always', () => {
|
||||
expect(shouldShowOverageMenu('always', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is never', () => {
|
||||
expect(shouldShowOverageMenu('never', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when creditBalance is null (ineligible)', () => {
|
||||
expect(shouldShowOverageMenu('ask', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowEmptyWalletMenu', () => {
|
||||
it('should return true when strategy is ask and balance is 0', () => {
|
||||
expect(shouldShowEmptyWalletMenu('ask', 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when strategy is always and balance is 0', () => {
|
||||
expect(shouldShowEmptyWalletMenu('always', 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when strategy is never', () => {
|
||||
expect(shouldShowEmptyWalletMenu('never', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when balance > 0', () => {
|
||||
expect(shouldShowEmptyWalletMenu('ask', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when creditBalance is null (ineligible)', () => {
|
||||
expect(shouldShowEmptyWalletMenu('ask', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOverageEligibleModel', () => {
|
||||
it('should return true for gemini-3-pro-preview', () => {
|
||||
expect(isOverageEligibleModel('gemini-3-pro-preview')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for gemini-3.1-pro-preview', () => {
|
||||
expect(isOverageEligibleModel('gemini-3.1-pro-preview')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for gemini-3.1-pro-preview-customtools', () => {
|
||||
expect(isOverageEligibleModel('gemini-3.1-pro-preview-customtools')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for gemini-3-flash-preview', () => {
|
||||
expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for gemini-2.5-pro', () => {
|
||||
expect(isOverageEligibleModel('gemini-2.5-pro')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for gemini-2.5-flash', () => {
|
||||
expect(isOverageEligibleModel('gemini-2.5-flash')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for custom model names', () => {
|
||||
expect(isOverageEligibleModel('my-custom-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
AvailableCredits,
|
||||
CreditType,
|
||||
GeminiUserTier,
|
||||
} from '../code_assist/types.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
/**
|
||||
* Strategy for handling quota exhaustion when AI credits are available.
|
||||
* - 'ask': Prompt the user each time
|
||||
* - 'always': Automatically use credits
|
||||
* - 'never': Never use credits, show standard fallback
|
||||
*/
|
||||
export type OverageStrategy = 'ask' | 'always' | 'never';
|
||||
|
||||
/** Credit type for Google One AI credits */
|
||||
export const G1_CREDIT_TYPE: CreditType = 'GOOGLE_ONE_AI';
|
||||
|
||||
/**
|
||||
* The set of models that support AI credits overage billing.
|
||||
* Only these models are eligible for the credits-based retry flow.
|
||||
*/
|
||||
export const OVERAGE_ELIGIBLE_MODELS = new Set([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Checks if a model is eligible for AI credits overage billing.
|
||||
* @param model The model name to check.
|
||||
* @returns true if the model supports credits overage, false otherwise.
|
||||
*/
|
||||
export function isOverageEligibleModel(model: string): boolean {
|
||||
return OVERAGE_ELIGIBLE_MODELS.has(model);
|
||||
}
|
||||
|
||||
/** Base URL for Google One AI page */
|
||||
const G1_AI_BASE_URL = 'https://one.google.com/ai';
|
||||
|
||||
/** AccountChooser URL for redirecting with email context */
|
||||
const ACCOUNT_CHOOSER_URL = 'https://accounts.google.com/AccountChooser';
|
||||
|
||||
/** UTM parameters for CLI tracking */
|
||||
const UTM_SOURCE = 'gemini_cli';
|
||||
// TODO: change to 'desktop' when G1 service fix is rolled out
|
||||
const UTM_MEDIUM = 'web';
|
||||
|
||||
/**
|
||||
* Wraps a URL in the AccountChooser redirect to maintain user context.
|
||||
* @param email User's email address for account selection
|
||||
* @param continueUrl The destination URL after account selection
|
||||
* @returns The full AccountChooser redirect URL
|
||||
*/
|
||||
export function wrapInAccountChooser(
|
||||
email: string,
|
||||
continueUrl: string,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
Email: email,
|
||||
continue: continueUrl,
|
||||
});
|
||||
return `${ACCOUNT_CHOOSER_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTM campaign identifiers per the design doc.
|
||||
*/
|
||||
export const G1_UTM_CAMPAIGNS = {
|
||||
/** From Interception Flow "Manage" link (user has credits) */
|
||||
MANAGE_ACTIVITY: 'hydrogen_cli_settings_ai_credits_activity_page',
|
||||
/** From "Manage" to add more credits */
|
||||
MANAGE_ADD_CREDITS: 'hydrogen_cli_settings_add_credits',
|
||||
/** From Empty Wallet Flow "Get AI Credits" link */
|
||||
EMPTY_WALLET_ADD_CREDITS: 'hydrogen_cli_insufficient_credits_add_credits',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Builds a G1 AI URL with UTM tracking parameters.
|
||||
* @param path The path segment (e.g., 'activity' or 'credits')
|
||||
* @param email User's email for AccountChooser wrapper
|
||||
* @param campaign The UTM campaign identifier
|
||||
* @returns The complete URL wrapped in AccountChooser
|
||||
*/
|
||||
export function buildG1Url(
|
||||
path: 'activity' | 'credits',
|
||||
email: string,
|
||||
campaign: string,
|
||||
): string {
|
||||
const baseUrl = `${G1_AI_BASE_URL}/${path}`;
|
||||
const params = new URLSearchParams({
|
||||
utm_source: UTM_SOURCE,
|
||||
utm_medium: UTM_MEDIUM,
|
||||
utm_campaign: campaign,
|
||||
});
|
||||
const urlWithUtm = `${baseUrl}?${params.toString()}`;
|
||||
return wrapInAccountChooser(email, urlWithUtm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the G1 AI credit balance from a tier's available credits.
|
||||
* @param tier The user tier to check
|
||||
* @returns The credit amount as a number, 0 if eligible but empty, or null if not eligible
|
||||
*/
|
||||
export function getG1CreditBalance(
|
||||
tier: GeminiUserTier | null | undefined,
|
||||
): number | null {
|
||||
if (!tier?.availableCredits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const g1Credits = tier.availableCredits.filter(
|
||||
(credit: AvailableCredits) => credit.creditType === G1_CREDIT_TYPE,
|
||||
);
|
||||
|
||||
if (g1Credits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// creditAmount is an int64 represented as string; sum all matching entries
|
||||
return g1Credits.reduce((sum, credit) => {
|
||||
const amount = parseInt(credit.creditAmount ?? '0', 10);
|
||||
return sum + (isNaN(amount) ? 0 : amount);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export const MIN_CREDIT_BALANCE = 50;
|
||||
|
||||
/**
|
||||
* Determines if credits should be automatically used based on the overage strategy.
|
||||
* @param strategy The configured overage strategy
|
||||
* @param creditBalance The available credit balance
|
||||
* @returns true if credits should be auto-used, false otherwise
|
||||
*/
|
||||
export function shouldAutoUseCredits(
|
||||
strategy: OverageStrategy,
|
||||
creditBalance: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
strategy === 'always' &&
|
||||
creditBalance != null &&
|
||||
creditBalance >= MIN_CREDIT_BALANCE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the overage menu should be shown based on the strategy.
|
||||
* @param strategy The configured overage strategy
|
||||
* @param creditBalance The available credit balance
|
||||
* @returns true if the menu should be shown
|
||||
*/
|
||||
export function shouldShowOverageMenu(
|
||||
strategy: OverageStrategy,
|
||||
creditBalance: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
strategy === 'ask' &&
|
||||
creditBalance != null &&
|
||||
creditBalance >= MIN_CREDIT_BALANCE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the empty wallet menu should be shown.
|
||||
* @param strategy The configured overage strategy
|
||||
* @param creditBalance The available credit balance
|
||||
* @returns true if the empty wallet menu should be shown
|
||||
*/
|
||||
export function shouldShowEmptyWalletMenu(
|
||||
strategy: OverageStrategy,
|
||||
creditBalance: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
strategy !== 'never' &&
|
||||
creditBalance != null &&
|
||||
creditBalance < MIN_CREDIT_BALANCE
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export * from './billing.js';
|
||||
@@ -73,6 +73,8 @@ describe('codeAssist', () => {
|
||||
'session-123',
|
||||
'free-tier',
|
||||
'free-tier-name',
|
||||
undefined,
|
||||
mockConfig,
|
||||
);
|
||||
expect(generator).toBeInstanceOf(MockedCodeAssistServer);
|
||||
});
|
||||
@@ -103,6 +105,8 @@ describe('codeAssist', () => {
|
||||
undefined, // No session ID
|
||||
'free-tier',
|
||||
'free-tier-name',
|
||||
undefined,
|
||||
mockConfig,
|
||||
);
|
||||
expect(generator).toBeInstanceOf(MockedCodeAssistServer);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export async function createCodeAssistContentGenerator(
|
||||
sessionId,
|
||||
userData.userTier,
|
||||
userData.userTierName,
|
||||
userData.paidTier,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ describe('converter', () => {
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes).toBeInstanceOf(GenerateContentResponse);
|
||||
expect(genaiRes.candidates).toEqual(codeAssistRes.response.candidates);
|
||||
expect(genaiRes.candidates).toEqual(codeAssistRes.response!.candidates);
|
||||
});
|
||||
|
||||
it('should handle prompt feedback and usage metadata', () => {
|
||||
@@ -266,10 +266,10 @@ describe('converter', () => {
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.promptFeedback).toEqual(
|
||||
codeAssistRes.response.promptFeedback,
|
||||
codeAssistRes.response!.promptFeedback,
|
||||
);
|
||||
expect(genaiRes.usageMetadata).toEqual(
|
||||
codeAssistRes.response.usageMetadata,
|
||||
codeAssistRes.response!.usageMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('converter', () => {
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.automaticFunctionCallingHistory).toEqual(
|
||||
codeAssistRes.response.automaticFunctionCallingHistory,
|
||||
codeAssistRes.response!.automaticFunctionCallingHistory,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,12 +27,15 @@ import type {
|
||||
ToolConfig,
|
||||
} from '@google/genai';
|
||||
import { GenerateContentResponse } from '@google/genai';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { Credits } from './types.js';
|
||||
|
||||
export interface CAGenerateContentRequest {
|
||||
model: string;
|
||||
project?: string;
|
||||
user_prompt_id?: string;
|
||||
request: VertexGenerateContentRequest;
|
||||
enabled_credit_types?: string[];
|
||||
}
|
||||
|
||||
interface VertexGenerateContentRequest {
|
||||
@@ -72,12 +75,14 @@ interface VertexGenerationConfig {
|
||||
}
|
||||
|
||||
export interface CaGenerateContentResponse {
|
||||
response: VertexGenerateContentResponse;
|
||||
response?: VertexGenerateContentResponse;
|
||||
traceId?: string;
|
||||
consumedCredits?: Credits[];
|
||||
remainingCredits?: Credits[];
|
||||
}
|
||||
|
||||
interface VertexGenerateContentResponse {
|
||||
candidates: Candidate[];
|
||||
candidates?: Candidate[];
|
||||
automaticFunctionCallingHistory?: Content[];
|
||||
promptFeedback?: GenerateContentResponsePromptFeedback;
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata;
|
||||
@@ -94,7 +99,7 @@ interface VertexCountTokenRequest {
|
||||
}
|
||||
|
||||
export interface CaCountTokenResponse {
|
||||
totalTokens: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export function toCountTokenRequest(
|
||||
@@ -111,8 +116,13 @@ export function toCountTokenRequest(
|
||||
export function fromCountTokenResponse(
|
||||
res: CaCountTokenResponse,
|
||||
): CountTokensResponse {
|
||||
if (res.totalTokens === undefined) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return totalTokens. Defaulting to 0.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
totalTokens: res.totalTokens,
|
||||
totalTokens: res.totalTokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,12 +131,14 @@ export function toGenerateContentRequest(
|
||||
userPromptId: string,
|
||||
project?: string,
|
||||
sessionId?: string,
|
||||
enabledCreditTypes?: string[],
|
||||
): CAGenerateContentRequest {
|
||||
return {
|
||||
model: req.model,
|
||||
project,
|
||||
user_prompt_id: userPromptId,
|
||||
request: toVertexGenerateContentRequest(req, sessionId),
|
||||
enabled_credit_types: enabledCreditTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,3 +311,16 @@ function toVertexGenerationConfig(
|
||||
thinkingConfig: config.thinkingConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromGenerateContentResponseUsage(
|
||||
metadata?: GenerateContentResponseUsageMetadata,
|
||||
): GenerateContentResponseUsageMetadata | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
promptTokenCount: metadata.promptTokenCount,
|
||||
candidatesTokenCount: metadata.candidatesTokenCount,
|
||||
totalTokenCount: metadata.totalTokenCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ describe('CodeAssistServer', () => {
|
||||
body: expect.any(String),
|
||||
signal: undefined,
|
||||
retryConfig: {
|
||||
retryDelay: 1000,
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
@@ -410,15 +411,7 @@ describe('CodeAssistServer', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: undefined,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
@@ -21,7 +21,10 @@ import type {
|
||||
ConversationInteraction,
|
||||
StreamingLatency,
|
||||
RecordCodeAssistMetricsRequest,
|
||||
GeminiUserTier,
|
||||
Credits,
|
||||
} from './types.js';
|
||||
import { UserTierId } from './types.js';
|
||||
import type {
|
||||
ListExperimentsRequest,
|
||||
ListExperimentsResponse,
|
||||
@@ -37,7 +40,15 @@ import type {
|
||||
import * as readline from 'node:readline';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import { UserTierId } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
G1_CREDIT_TYPE,
|
||||
getG1CreditBalance,
|
||||
isOverageEligibleModel,
|
||||
shouldAutoUseCredits,
|
||||
} from '../billing/billing.js';
|
||||
import { logBillingEvent } from '../telemetry/loggers.js';
|
||||
import { CreditsUsedEvent } from '../telemetry/billingEvents.js';
|
||||
import type {
|
||||
CaCountTokenResponse,
|
||||
CaGenerateContentResponse,
|
||||
@@ -62,6 +73,7 @@ export interface HttpOptions {
|
||||
|
||||
export const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
||||
export const CODE_ASSIST_API_VERSION = 'v1internal';
|
||||
const GENERATE_CONTENT_RETRY_DELAY_IN_MILLISECONDS = 1000;
|
||||
|
||||
export class CodeAssistServer implements ContentGenerator {
|
||||
constructor(
|
||||
@@ -71,6 +83,8 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
readonly sessionId?: string,
|
||||
readonly userTier?: UserTierId,
|
||||
readonly userTierName?: string,
|
||||
readonly paidTier?: GeminiUserTier,
|
||||
readonly config?: Config,
|
||||
) {}
|
||||
|
||||
async generateContentStream(
|
||||
@@ -79,6 +93,19 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
role: LlmRole,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const autoUse = this.config
|
||||
? shouldAutoUseCredits(
|
||||
this.config.getBillingSettings().overageStrategy,
|
||||
getG1CreditBalance(this.paidTier),
|
||||
)
|
||||
: false;
|
||||
const modelIsEligible = isOverageEligibleModel(req.model);
|
||||
const shouldEnableCredits = modelIsEligible && autoUse;
|
||||
|
||||
const enabledCreditTypes = shouldEnableCredits
|
||||
? ([G1_CREDIT_TYPE] as string[])
|
||||
: undefined;
|
||||
|
||||
const responses =
|
||||
await this.requestStreamingPost<CaGenerateContentResponse>(
|
||||
'streamGenerateContent',
|
||||
@@ -87,6 +114,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
enabledCreditTypes,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
);
|
||||
@@ -98,6 +126,9 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
return (async function* (
|
||||
server: CodeAssistServer,
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
let totalConsumed = 0;
|
||||
let lastRemaining = 0;
|
||||
|
||||
for await (const response of responses) {
|
||||
if (isFirst) {
|
||||
streamingLatency.firstMessageLatency = formatProtoJsonDuration(
|
||||
@@ -120,8 +151,38 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req.config?.abortSignal,
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
for (const credit of response.consumedCredits) {
|
||||
if (credit.creditType === G1_CREDIT_TYPE && credit.creditAmount) {
|
||||
totalConsumed += parseInt(credit.creditAmount, 10) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.remainingCredits) {
|
||||
// Sum all G1 credit entries for consistency with getG1CreditBalance
|
||||
lastRemaining = response.remainingCredits.reduce((sum, credit) => {
|
||||
if (credit.creditType === G1_CREDIT_TYPE && credit.creditAmount) {
|
||||
return sum + (parseInt(credit.creditAmount, 10) || 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
server.updateCredits(response.remainingCredits);
|
||||
}
|
||||
|
||||
yield translatedResponse;
|
||||
}
|
||||
|
||||
// Emit credits used telemetry after the stream completes
|
||||
if (totalConsumed > 0 && server.config) {
|
||||
logBillingEvent(
|
||||
server.config,
|
||||
new CreditsUsedEvent(
|
||||
req.model ?? 'unknown',
|
||||
totalConsumed,
|
||||
lastRemaining,
|
||||
),
|
||||
);
|
||||
}
|
||||
})(this);
|
||||
}
|
||||
|
||||
@@ -139,8 +200,10 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
undefined,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
GENERATE_CONTENT_RETRY_DELAY_IN_MILLISECONDS,
|
||||
);
|
||||
const duration = formatProtoJsonDuration(Date.now() - start);
|
||||
const streamingLatency: StreamingLatency = {
|
||||
@@ -158,9 +221,29 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req.config?.abortSignal,
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
this.updateCredits(response.remainingCredits);
|
||||
}
|
||||
|
||||
return translatedResponse;
|
||||
}
|
||||
|
||||
private updateCredits(remainingCredits: Credits[]): void {
|
||||
if (!this.paidTier) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the G1 credits entries with the latest remaining amounts.
|
||||
// Non-G1 credits are preserved as-is.
|
||||
const nonG1Credits = (this.paidTier.availableCredits ?? []).filter(
|
||||
(c) => c.creditType !== G1_CREDIT_TYPE,
|
||||
);
|
||||
const updatedG1Credits = remainingCredits.filter(
|
||||
(c) => c.creditType === G1_CREDIT_TYPE,
|
||||
);
|
||||
this.paidTier.availableCredits = [...nonG1Credits, ...updatedG1Credits];
|
||||
}
|
||||
|
||||
async onboardUser(
|
||||
req: OnboardUserRequest,
|
||||
): Promise<LongRunningOperationResponse> {
|
||||
@@ -190,6 +273,25 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAvailableCredits(): Promise<void> {
|
||||
if (!this.paidTier) {
|
||||
return;
|
||||
}
|
||||
const res = await this.loadCodeAssist({
|
||||
cloudaicompanionProject: this.projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: this.projectId,
|
||||
},
|
||||
mode: 'HEALTH_CHECK',
|
||||
});
|
||||
if (res.paidTier?.availableCredits) {
|
||||
this.paidTier.availableCredits = res.paidTier.availableCredits;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAdminControls(
|
||||
req: FetchAdminControlsRequest,
|
||||
): Promise<FetchAdminControlsResponse> {
|
||||
@@ -294,6 +396,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
method: string,
|
||||
req: object,
|
||||
signal?: AbortSignal,
|
||||
retryDelay: number = 100,
|
||||
): Promise<T> {
|
||||
const res = await this.client.request<T>({
|
||||
url: this.getMethodUrl(method),
|
||||
@@ -306,6 +409,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
retryConfig: {
|
||||
retryDelay,
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
@@ -361,15 +465,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
responseType: 'stream',
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (async function* (): AsyncGenerator<T> {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AuthClient } from 'google-auth-library';
|
||||
import type { ValidationHandler } from '../fallback/types.js';
|
||||
import { ChangeAuthRequestedError } from '../utils/errors.js';
|
||||
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export class ProjectIdRequiredError extends Error {
|
||||
constructor() {
|
||||
@@ -51,6 +52,7 @@ export interface UserData {
|
||||
projectId: string;
|
||||
userTier: UserTierId;
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,12 +132,22 @@ export async function setupUser(
|
||||
}
|
||||
|
||||
if (loadRes.currentTier) {
|
||||
if (!loadRes.paidTier?.id && !loadRes.currentTier.id) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return a user tier ID. Defaulting to STANDARD tier.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!loadRes.cloudaicompanionProject) {
|
||||
if (projectId) {
|
||||
return {
|
||||
projectId,
|
||||
userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id,
|
||||
userTier:
|
||||
loadRes.paidTier?.id ??
|
||||
loadRes.currentTier.id ??
|
||||
UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
paidTier: loadRes.paidTier ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,13 +156,21 @@ export async function setupUser(
|
||||
}
|
||||
return {
|
||||
projectId: loadRes.cloudaicompanionProject,
|
||||
userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id,
|
||||
userTier:
|
||||
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
paidTier: loadRes.paidTier ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const tier = getOnboardTier(loadRes);
|
||||
|
||||
if (!tier.id) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return an onboarding tier ID. Defaulting to STANDARD tier.',
|
||||
);
|
||||
}
|
||||
|
||||
let onboardReq: OnboardUserRequest;
|
||||
if (tier.id === UserTierId.FREE) {
|
||||
// The free tier uses a managed google cloud project. Setting a project in the `onboardUser` request causes a `Precondition Failed` error.
|
||||
@@ -183,7 +203,7 @@ export async function setupUser(
|
||||
if (projectId) {
|
||||
return {
|
||||
projectId,
|
||||
userTier: tier.id,
|
||||
userTier: tier.id ?? UserTierId.STANDARD,
|
||||
userTierName: tier.name,
|
||||
};
|
||||
}
|
||||
@@ -193,7 +213,7 @@ export async function setupUser(
|
||||
|
||||
return {
|
||||
projectId: lroRes.response.cloudaicompanionProject.id,
|
||||
userTier: tier.id,
|
||||
userTier: tier.id ?? UserTierId.STANDARD,
|
||||
userTierName: tier.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,11 +39,41 @@ export type ClientMetadataPluginType =
|
||||
| 'AIPLUGIN_INTELLIJ'
|
||||
| 'AIPLUGIN_STUDIO';
|
||||
|
||||
/**
|
||||
* Credit types that can be used for API consumption.
|
||||
*/
|
||||
export type CreditType = 'CREDIT_TYPE_UNSPECIFIED' | 'GOOGLE_ONE_AI';
|
||||
|
||||
/**
|
||||
* Represents a credit amount for a specific credit type.
|
||||
* Used in LoadCodeAssistResponse for available credits and
|
||||
* in GenerateContentResponse for consumed/remaining credits.
|
||||
*/
|
||||
export interface Credits {
|
||||
creditType: CreditType;
|
||||
creditAmount: string; // int64 represented as string in JSON
|
||||
}
|
||||
|
||||
/** Alias for Credits used in available_credits context */
|
||||
export type AvailableCredits = Credits;
|
||||
|
||||
/** Alias for Credits used in consumedCredits context */
|
||||
export type ConsumedCredits = Credits;
|
||||
|
||||
/** Alias for Credits used in remainingCredits context */
|
||||
export type RemainingCredits = Credits;
|
||||
|
||||
export interface LoadCodeAssistRequest {
|
||||
cloudaicompanionProject?: string;
|
||||
metadata: ClientMetadata;
|
||||
mode?: LoadCodeAssistMode;
|
||||
}
|
||||
|
||||
export type LoadCodeAssistMode =
|
||||
| 'MODE_UNSPECIFIED'
|
||||
| 'FULL_ELIGIBILITY_CHECK'
|
||||
| 'HEALTH_CHECK';
|
||||
|
||||
/**
|
||||
* Represents LoadCodeAssistResponse proto json field
|
||||
* http://google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=224
|
||||
@@ -60,7 +90,7 @@ export interface LoadCodeAssistResponse {
|
||||
* GeminiUserTier reflects the structure received from the CodeAssist when calling LoadCodeAssist.
|
||||
*/
|
||||
export interface GeminiUserTier {
|
||||
id: UserTierId;
|
||||
id?: UserTierId;
|
||||
name?: string;
|
||||
description?: string;
|
||||
// This value is used to declare whether a given tier requires the user to configure the project setting on the IDE settings or not.
|
||||
@@ -69,6 +99,8 @@ export interface GeminiUserTier {
|
||||
privacyNotice?: PrivacyNotice;
|
||||
hasAcceptedTos?: boolean;
|
||||
hasOnboardedPreviously?: boolean;
|
||||
/** Available AI credits for this tier (e.g., Google One AI credits) */
|
||||
availableCredits?: AvailableCredits[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,10 +111,10 @@ export interface GeminiUserTier {
|
||||
* @param tierName name of the tier.
|
||||
*/
|
||||
export interface IneligibleTier {
|
||||
reasonCode: IneligibleTierReasonCode;
|
||||
reasonMessage: string;
|
||||
tierId: UserTierId;
|
||||
tierName: string;
|
||||
reasonCode?: IneligibleTierReasonCode;
|
||||
reasonMessage?: string;
|
||||
tierId?: UserTierId;
|
||||
tierName?: string;
|
||||
validationErrorMessage?: string;
|
||||
validationUrl?: string;
|
||||
validationUrlLinkText?: string;
|
||||
@@ -127,7 +159,7 @@ export type UserTierId = (typeof UserTierId)[keyof typeof UserTierId] | string;
|
||||
* privacy notice.
|
||||
*/
|
||||
export interface PrivacyNotice {
|
||||
showNotice: boolean;
|
||||
showNotice?: boolean;
|
||||
noticeText?: string;
|
||||
}
|
||||
|
||||
@@ -145,7 +177,7 @@ export interface OnboardUserRequest {
|
||||
* http://google3/google/longrunning/operations.proto;rcl=698857719;l=107
|
||||
*/
|
||||
export interface LongRunningOperationResponse {
|
||||
name: string;
|
||||
name?: string;
|
||||
done?: boolean;
|
||||
response?: OnboardUserResponse;
|
||||
}
|
||||
@@ -157,8 +189,8 @@ export interface LongRunningOperationResponse {
|
||||
export interface OnboardUserResponse {
|
||||
// tslint:disable-next-line:enforce-name-casing This is the name of the field in the proto.
|
||||
cloudaicompanionProject?: {
|
||||
id: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,7 +227,7 @@ export interface SetCodeAssistGlobalUserSettingRequest {
|
||||
|
||||
export interface CodeAssistGlobalUserSettingResponse {
|
||||
cloudaicompanionProject?: string;
|
||||
freeTierDataCollectionOptin: boolean;
|
||||
freeTierDataCollectionOptin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
} from '../core/contentGenerator.js';
|
||||
import type { OverageStrategy } from '../billing/billing.js';
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
@@ -116,6 +117,7 @@ import {
|
||||
import { HookSystem } from '../hooks/index.js';
|
||||
import type {
|
||||
UserTierId,
|
||||
GeminiUserTier,
|
||||
RetrieveUserQuotaResponse,
|
||||
AdminControlsSettings,
|
||||
} from '../code_assist/types.js';
|
||||
@@ -365,6 +367,7 @@ import {
|
||||
SimpleExtensionLoader,
|
||||
} from '../utils/extensionLoader.js';
|
||||
import { McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import { type McpContext } from '../tools/mcp-client.js';
|
||||
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import {
|
||||
@@ -573,9 +576,12 @@ export interface ConfigParameters {
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
}
|
||||
|
||||
export class Config {
|
||||
export class Config implements McpContext {
|
||||
private toolRegistry!: ToolRegistry;
|
||||
private mcpClientManager?: McpClientManager;
|
||||
private allowedMcpServers: string[];
|
||||
@@ -754,6 +760,10 @@ export class Config {
|
||||
}>)
|
||||
| undefined;
|
||||
|
||||
private readonly billing: {
|
||||
overageStrategy: OverageStrategy;
|
||||
};
|
||||
|
||||
private readonly enableAgents: boolean;
|
||||
private agents: AgentSettings;
|
||||
private readonly enableEventDrivenScheduler: boolean;
|
||||
@@ -1001,6 +1011,10 @@ export class Config {
|
||||
this.onModelChange = params.onModelChange;
|
||||
this.onReload = params.onReload;
|
||||
|
||||
this.billing = {
|
||||
overageStrategy: params.billing?.overageStrategy ?? 'ask',
|
||||
};
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
}
|
||||
@@ -1264,6 +1278,10 @@ export class Config {
|
||||
return this.contentGenerator?.userTierName;
|
||||
}
|
||||
|
||||
getUserPaidTier(): GeminiUserTier | undefined {
|
||||
return this.contentGenerator?.paidTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to the BaseLlmClient for stateless LLM operations.
|
||||
*/
|
||||
@@ -1578,6 +1596,19 @@ export class Config {
|
||||
this.hasAccessToPreviewModel = hasAccess;
|
||||
}
|
||||
|
||||
async refreshAvailableCredits(): Promise<void> {
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (!codeAssistServer) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await codeAssistServer.refreshAvailableCredits();
|
||||
} catch {
|
||||
// Non-fatal: proceed even if refresh fails.
|
||||
// The actual credit balance will be verified server-side.
|
||||
}
|
||||
}
|
||||
|
||||
async refreshUserQuota(): Promise<RetrieveUserQuotaResponse | undefined> {
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (!codeAssistServer || !codeAssistServer.projectId) {
|
||||
@@ -1739,6 +1770,33 @@ export class Config {
|
||||
return this.mcpClientManager;
|
||||
}
|
||||
|
||||
setUserInteractedWithMcp(): void {
|
||||
this.mcpClientManager?.setUserInteractedWithMcp();
|
||||
}
|
||||
|
||||
/** @deprecated Use getMcpClientManager().getLastError() directly */
|
||||
getLastMcpError(serverName: string): string | undefined {
|
||||
return this.mcpClientManager?.getLastError(serverName);
|
||||
}
|
||||
|
||||
emitMcpDiagnostic(
|
||||
severity: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
error?: unknown,
|
||||
serverName?: string,
|
||||
): void {
|
||||
if (this.mcpClientManager) {
|
||||
this.mcpClientManager.emitDiagnostic(
|
||||
severity,
|
||||
message,
|
||||
error,
|
||||
serverName,
|
||||
);
|
||||
} else {
|
||||
coreEvents.emitFeedback(severity, message, error);
|
||||
}
|
||||
}
|
||||
|
||||
getAllowedMcpServers(): string[] | undefined {
|
||||
return this.allowedMcpServers;
|
||||
}
|
||||
@@ -2052,6 +2110,19 @@ export class Config {
|
||||
return this.telemetrySettings.outfile;
|
||||
}
|
||||
|
||||
getBillingSettings(): { overageStrategy: OverageStrategy } {
|
||||
return this.billing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the overage strategy at runtime.
|
||||
* Used to switch from 'ask' to 'always' after the user accepts credits
|
||||
* via the overage dialog, so subsequent API calls auto-include credits.
|
||||
*/
|
||||
setOverageStrategy(strategy: OverageStrategy): void {
|
||||
this.billing.overageStrategy = strategy;
|
||||
}
|
||||
|
||||
getTelemetryUseCollector(): boolean {
|
||||
return this.telemetrySettings.useCollector ?? false;
|
||||
}
|
||||
|
||||
@@ -169,10 +169,7 @@ export class Storage {
|
||||
}
|
||||
|
||||
getAutoSavedPolicyPath(): string {
|
||||
return path.join(
|
||||
this.getWorkspacePoliciesDir(),
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME);
|
||||
}
|
||||
|
||||
ensureProjectTempDirExists(): void {
|
||||
|
||||
@@ -152,7 +152,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -326,7 +326,7 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -607,7 +607,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -758,7 +758,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -897,7 +897,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1019,7 +1019,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1656,7 +1656,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1807,7 +1807,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1962,7 +1962,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2117,7 +2117,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2268,7 +2268,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2411,7 +2411,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2561,7 +2561,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2712,7 +2712,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3104,7 +3104,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3255,7 +3255,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3518,7 +3518,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3669,7 +3669,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
|
||||
@@ -17,7 +17,7 @@ import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import { InstallationManager } from '../utils/installationManager.js';
|
||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||
@@ -49,6 +49,8 @@ export interface ContentGenerator {
|
||||
userTier?: UserTierId;
|
||||
|
||||
userTierName?: string;
|
||||
|
||||
paidTier?: GeminiUserTier;
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@google/genai';
|
||||
import { promises } from 'node:fs';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||
import type { LlmRole } from '../telemetry/types.js';
|
||||
|
||||
@@ -44,6 +44,7 @@ export class FakeContentGenerator implements ContentGenerator {
|
||||
private callCounter = 0;
|
||||
userTier?: UserTierId;
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
|
||||
constructor(private readonly responses: FakeResponse[]) {}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '../telemetry/types.js';
|
||||
import type { LlmRole } from '../telemetry/llmRole.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import {
|
||||
logApiError,
|
||||
logApiRequest,
|
||||
@@ -163,6 +163,10 @@ export class LoggingContentGenerator implements ContentGenerator {
|
||||
return this.wrapped.userTierName;
|
||||
}
|
||||
|
||||
get paidTier(): GeminiUserTier | undefined {
|
||||
return this.wrapped.paidTier;
|
||||
}
|
||||
|
||||
private logApiRequest(
|
||||
contents: Content[],
|
||||
model: string,
|
||||
|
||||
@@ -140,6 +140,9 @@ async function processIntent(
|
||||
// based on the availability service state (which is updated before this).
|
||||
return true;
|
||||
|
||||
case 'retry_with_credits':
|
||||
return true;
|
||||
|
||||
case 'stop':
|
||||
// Do not switch model on stop. User wants to stay on current model (and stop).
|
||||
return false;
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
export type FallbackIntent =
|
||||
| 'retry_always' // Retry with fallback model and stick to it for future requests.
|
||||
| 'retry_once' // Retry with fallback model for this request only.
|
||||
| 'retry_with_credits' // Retry the current request using Google One AI credits (and potentially future ones if strategy is 'always').
|
||||
| 'stop' // Switch to fallback for future requests, but stop the current request.
|
||||
| 'retry_later' // Stop the current request and do not fallback. Intend to try again later with the same model.
|
||||
| 'upgrade'; // Give user an option to upgrade the tier.
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './policy/policy-engine.js';
|
||||
export * from './policy/toml-loader.js';
|
||||
export * from './policy/config.js';
|
||||
export * from './policy/integrity.js';
|
||||
export * from './billing/index.js';
|
||||
export * from './confirmation-bus/types.js';
|
||||
export * from './confirmation-bus/message-bus.js';
|
||||
|
||||
@@ -76,6 +77,7 @@ export * from './utils/quotaErrorDetection.js';
|
||||
export * from './utils/userAccountManager.js';
|
||||
export * from './utils/authConsent.js';
|
||||
export * from './utils/googleQuotaErrors.js';
|
||||
export * from './utils/googleErrors.js';
|
||||
export * from './utils/fileUtils.js';
|
||||
export * from './utils/planUtils.js';
|
||||
export * from './utils/approvalModeUtils.js';
|
||||
@@ -98,6 +100,7 @@ export * from './utils/ignorePatterns.js';
|
||||
export * from './utils/partUtils.js';
|
||||
export * from './utils/promptIdContext.js';
|
||||
export * from './utils/thoughtUtils.js';
|
||||
export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/debugLogger.js';
|
||||
export * from './utils/events.js';
|
||||
export * from './utils/extensionLoader.js';
|
||||
@@ -183,6 +186,8 @@ export { OAuthUtils } from './mcp/oauth-utils.js';
|
||||
|
||||
// Export telemetry functions
|
||||
export * from './telemetry/index.js';
|
||||
export * from './telemetry/billingEvents.js';
|
||||
export { logBillingEvent } from './telemetry/loggers.js';
|
||||
export { sessionId, createSessionId } from './utils/session.js';
|
||||
export * from './utils/compatibility.js';
|
||||
export * from './utils/browser.js';
|
||||
|
||||
@@ -516,9 +516,8 @@ export function createPolicyUpdater(
|
||||
if (message.persist) {
|
||||
persistenceQueue = persistenceQueue.then(async () => {
|
||||
try {
|
||||
const workspacePoliciesDir = storage.getWorkspacePoliciesDir();
|
||||
await fs.mkdir(workspacePoliciesDir, { recursive: true });
|
||||
const policyFile = storage.getAutoSavedPolicyPath();
|
||||
await fs.mkdir(path.dirname(policyFile), { recursive: true });
|
||||
|
||||
// Read existing file
|
||||
let existingData: { rule?: TomlRule[] } = {};
|
||||
|
||||
@@ -48,14 +48,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should persist policy when persist flag is true', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
@@ -79,8 +73,7 @@ describe('createPolicyUpdater', () => {
|
||||
// Wait for async operations (microtasks)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled();
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, {
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -115,14 +108,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should persist policy with commandPrefix when provided', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
@@ -168,14 +155,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should persist policy with mcpName and toolName when provided', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
@@ -214,14 +195,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should escape special characters in toolName and mcpName', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
|
||||
@@ -50,8 +50,8 @@ describe('createPolicyUpdater', () => {
|
||||
|
||||
messageBus = new MessageBus(policyEngine);
|
||||
mockStorage = new Storage('/mock/project');
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
'/mock/project/.gemini/policies',
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(
|
||||
'/mock/user/.gemini/policies/auto-saved.toml',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ export function renderOperationalGuidelines(
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with ${formatToolName(SHELL_TOOL_NAME)} that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with ${formatToolName(SHELL_TOOL_NAME)} that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use ${formatToolName(ASK_USER_TOOL_NAME)} to ask for permission to run a command.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PolicyDecision } from '../policy/types.js';
|
||||
import {
|
||||
ToolConfirmationOutcome,
|
||||
type AnyDeclarativeTool,
|
||||
Kind,
|
||||
} from '../tools/tools.js';
|
||||
import { getToolSuggestion } from '../utils/tool-utils.js';
|
||||
import { runInDevTraceSpan } from '../telemetry/trace.js';
|
||||
@@ -427,11 +428,11 @@ export class Scheduler {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the first tool is read-only, batch all contiguous read-only tools.
|
||||
if (next.tool?.isReadOnly) {
|
||||
// If the first tool is parallelizable, batch all contiguous parallelizable tools.
|
||||
if (this._isParallelizable(next.tool)) {
|
||||
while (this.state.queueLength > 0) {
|
||||
const peeked = this.state.peekQueue();
|
||||
if (peeked && peeked.tool?.isReadOnly) {
|
||||
if (peeked && this._isParallelizable(peeked.tool)) {
|
||||
this.state.dequeue();
|
||||
} else {
|
||||
break;
|
||||
@@ -516,6 +517,11 @@ export class Scheduler {
|
||||
return false;
|
||||
}
|
||||
|
||||
private _isParallelizable(tool?: AnyDeclarativeTool): boolean {
|
||||
if (!tool) return false;
|
||||
return tool.isReadOnly || tool.kind === Kind.Agent;
|
||||
}
|
||||
|
||||
private async _processValidatingCall(
|
||||
active: ValidatingToolCall,
|
||||
signal: AbortSignal,
|
||||
|
||||
@@ -70,6 +70,7 @@ import { ApprovalMode, PolicyDecision } from '../policy/types.js';
|
||||
import {
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
Kind,
|
||||
} from '../tools/tools.js';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
@@ -124,18 +125,51 @@ describe('Scheduler Parallel Execution', () => {
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
};
|
||||
|
||||
const agentReq1: ToolCallRequestInfo = {
|
||||
callId: 'agent-1',
|
||||
name: 'agent-tool-1',
|
||||
args: { query: 'do thing 1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
};
|
||||
|
||||
const agentReq2: ToolCallRequestInfo = {
|
||||
callId: 'agent-2',
|
||||
name: 'agent-tool-2',
|
||||
args: { query: 'do thing 2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
};
|
||||
|
||||
const readTool1 = {
|
||||
name: 'read-tool-1',
|
||||
kind: Kind.Read,
|
||||
isReadOnly: true,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const readTool2 = {
|
||||
name: 'read-tool-2',
|
||||
kind: Kind.Read,
|
||||
isReadOnly: true,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const writeTool = {
|
||||
name: 'write-tool',
|
||||
kind: Kind.Execute,
|
||||
isReadOnly: false,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const agentTool1 = {
|
||||
name: 'agent-tool-1',
|
||||
kind: Kind.Agent,
|
||||
isReadOnly: false,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const agentTool2 = {
|
||||
name: 'agent-tool-2',
|
||||
kind: Kind.Agent,
|
||||
isReadOnly: false,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
@@ -160,11 +194,19 @@ describe('Scheduler Parallel Execution', () => {
|
||||
if (name === 'read-tool-1') return readTool1;
|
||||
if (name === 'read-tool-2') return readTool2;
|
||||
if (name === 'write-tool') return writeTool;
|
||||
if (name === 'agent-tool-1') return agentTool1;
|
||||
if (name === 'agent-tool-2') return agentTool2;
|
||||
return undefined;
|
||||
}),
|
||||
getAllToolNames: vi
|
||||
.fn()
|
||||
.mockReturnValue(['read-tool-1', 'read-tool-2', 'write-tool']),
|
||||
.mockReturnValue([
|
||||
'read-tool-1',
|
||||
'read-tool-2',
|
||||
'write-tool',
|
||||
'agent-tool-1',
|
||||
'agent-tool-2',
|
||||
]),
|
||||
} as unknown as Mocked<ToolRegistry>;
|
||||
|
||||
mockConfig = {
|
||||
@@ -279,6 +321,12 @@ describe('Scheduler Parallel Execution', () => {
|
||||
vi.mocked(writeTool.build).mockReturnValue(
|
||||
mockInvocation as unknown as AnyToolInvocation,
|
||||
);
|
||||
vi.mocked(agentTool1.build).mockReturnValue(
|
||||
mockInvocation as unknown as AnyToolInvocation,
|
||||
);
|
||||
vi.mocked(agentTool2.build).mockReturnValue(
|
||||
mockInvocation as unknown as AnyToolInvocation,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -418,4 +466,41 @@ describe('Scheduler Parallel Execution', () => {
|
||||
expect(executionLog.indexOf('start-call-4')).toBeGreaterThan(end3);
|
||||
expect(executionLog.indexOf('start-call-5')).toBeGreaterThan(end3);
|
||||
});
|
||||
|
||||
it('should execute [Agent, Agent, Sequential, Parallelizable] in three waves', async () => {
|
||||
const executionLog: string[] = [];
|
||||
|
||||
mockExecutor.execute.mockImplementation(async ({ call }) => {
|
||||
const id = call.request.callId;
|
||||
executionLog.push(`start-${id}`);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
executionLog.push(`end-${id}`);
|
||||
return {
|
||||
status: 'success',
|
||||
response: { callId: id, responseParts: [] },
|
||||
} as unknown as SuccessfulToolCall;
|
||||
});
|
||||
|
||||
// Schedule: agentReq1 (Parallel), agentReq2 (Parallel), req3 (Sequential/Write), req1 (Parallel/Read)
|
||||
await scheduler.schedule([agentReq1, agentReq2, req3, req1], signal);
|
||||
|
||||
// Wave 1: agent-1, agent-2 (parallel)
|
||||
expect(executionLog.slice(0, 2)).toContain('start-agent-1');
|
||||
expect(executionLog.slice(0, 2)).toContain('start-agent-2');
|
||||
|
||||
// Both agents must end before anything else starts
|
||||
const endAgent1 = executionLog.indexOf('end-agent-1');
|
||||
const endAgent2 = executionLog.indexOf('end-agent-2');
|
||||
const wave1End = Math.max(endAgent1, endAgent2);
|
||||
|
||||
// Wave 2: call-3 (sequential/write)
|
||||
const start3 = executionLog.indexOf('start-call-3');
|
||||
const end3 = executionLog.indexOf('end-call-3');
|
||||
expect(start3).toBeGreaterThan(wave1End);
|
||||
expect(end3).toBeGreaterThan(start3);
|
||||
|
||||
// Wave 3: call-1 (parallelizable/read)
|
||||
const start1 = executionLog.indexOf('start-call-1');
|
||||
expect(start1).toBeGreaterThan(end3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import {
|
||||
OverageMenuShownEvent,
|
||||
OverageOptionSelectedEvent,
|
||||
EmptyWalletMenuShownEvent,
|
||||
CreditPurchaseClickEvent,
|
||||
CreditsUsedEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
EVENT_OVERAGE_MENU_SHOWN,
|
||||
EVENT_OVERAGE_OPTION_SELECTED,
|
||||
EVENT_EMPTY_WALLET_MENU_SHOWN,
|
||||
EVENT_CREDIT_PURCHASE_CLICK,
|
||||
EVENT_CREDITS_USED,
|
||||
EVENT_API_KEY_UPDATED,
|
||||
} from './billingEvents.js';
|
||||
|
||||
describe('billingEvents', () => {
|
||||
const fakeConfig = makeFakeConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T10:30:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('OverageMenuShownEvent', () => {
|
||||
it('should construct with correct properties', () => {
|
||||
const event = new OverageMenuShownEvent(
|
||||
'gemini-3-pro-preview',
|
||||
500,
|
||||
'ask',
|
||||
);
|
||||
expect(event['event.name']).toBe('overage_menu_shown');
|
||||
expect(event.model).toBe('gemini-3-pro-preview');
|
||||
expect(event.credit_balance).toBe(500);
|
||||
expect(event.overage_strategy).toBe('ask');
|
||||
});
|
||||
|
||||
it('should produce correct OpenTelemetry attributes', () => {
|
||||
const event = new OverageMenuShownEvent(
|
||||
'gemini-3-pro-preview',
|
||||
500,
|
||||
'ask',
|
||||
);
|
||||
const attrs = event.toOpenTelemetryAttributes(fakeConfig);
|
||||
expect(attrs['event.name']).toBe(EVENT_OVERAGE_MENU_SHOWN);
|
||||
expect(attrs['model']).toBe('gemini-3-pro-preview');
|
||||
expect(attrs['credit_balance']).toBe(500);
|
||||
expect(attrs['overage_strategy']).toBe('ask');
|
||||
});
|
||||
|
||||
it('should produce a human-readable log body', () => {
|
||||
const event = new OverageMenuShownEvent(
|
||||
'gemini-3-pro-preview',
|
||||
500,
|
||||
'ask',
|
||||
);
|
||||
expect(event.toLogBody()).toContain('gemini-3-pro-preview');
|
||||
expect(event.toLogBody()).toContain('500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OverageOptionSelectedEvent', () => {
|
||||
it('should construct with correct properties', () => {
|
||||
const event = new OverageOptionSelectedEvent(
|
||||
'gemini-3-pro-preview',
|
||||
'use_credits',
|
||||
100,
|
||||
);
|
||||
expect(event['event.name']).toBe('overage_option_selected');
|
||||
expect(event.selected_option).toBe('use_credits');
|
||||
expect(event.credit_balance).toBe(100);
|
||||
});
|
||||
|
||||
it('should produce correct OpenTelemetry attributes', () => {
|
||||
const event = new OverageOptionSelectedEvent(
|
||||
'gemini-3-pro-preview',
|
||||
'use_fallback',
|
||||
200,
|
||||
);
|
||||
const attrs = event.toOpenTelemetryAttributes(fakeConfig);
|
||||
expect(attrs['event.name']).toBe(EVENT_OVERAGE_OPTION_SELECTED);
|
||||
expect(attrs['selected_option']).toBe('use_fallback');
|
||||
});
|
||||
|
||||
it('should produce a human-readable log body', () => {
|
||||
const event = new OverageOptionSelectedEvent(
|
||||
'gemini-3-pro-preview',
|
||||
'manage',
|
||||
100,
|
||||
);
|
||||
expect(event.toLogBody()).toContain('manage');
|
||||
expect(event.toLogBody()).toContain('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyWalletMenuShownEvent', () => {
|
||||
it('should construct with correct properties', () => {
|
||||
const event = new EmptyWalletMenuShownEvent('gemini-3-pro-preview');
|
||||
expect(event['event.name']).toBe('empty_wallet_menu_shown');
|
||||
expect(event.model).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
|
||||
it('should produce correct OpenTelemetry attributes', () => {
|
||||
const event = new EmptyWalletMenuShownEvent('gemini-3-pro-preview');
|
||||
const attrs = event.toOpenTelemetryAttributes(fakeConfig);
|
||||
expect(attrs['event.name']).toBe(EVENT_EMPTY_WALLET_MENU_SHOWN);
|
||||
expect(attrs['model']).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
|
||||
it('should produce a human-readable log body', () => {
|
||||
const event = new EmptyWalletMenuShownEvent('gemini-3-pro-preview');
|
||||
expect(event.toLogBody()).toContain('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreditPurchaseClickEvent', () => {
|
||||
it('should construct with correct properties', () => {
|
||||
const event = new CreditPurchaseClickEvent(
|
||||
'empty_wallet_menu',
|
||||
'gemini-3-pro-preview',
|
||||
);
|
||||
expect(event['event.name']).toBe('credit_purchase_click');
|
||||
expect(event.source).toBe('empty_wallet_menu');
|
||||
expect(event.model).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
|
||||
it('should produce correct OpenTelemetry attributes', () => {
|
||||
const event = new CreditPurchaseClickEvent(
|
||||
'overage_menu',
|
||||
'gemini-3-pro-preview',
|
||||
);
|
||||
const attrs = event.toOpenTelemetryAttributes(fakeConfig);
|
||||
expect(attrs['event.name']).toBe(EVENT_CREDIT_PURCHASE_CLICK);
|
||||
expect(attrs['source']).toBe('overage_menu');
|
||||
});
|
||||
|
||||
it('should produce a human-readable log body', () => {
|
||||
const event = new CreditPurchaseClickEvent(
|
||||
'manage',
|
||||
'gemini-3-pro-preview',
|
||||
);
|
||||
expect(event.toLogBody()).toContain('manage');
|
||||
expect(event.toLogBody()).toContain('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreditsUsedEvent', () => {
|
||||
it('should construct with correct properties', () => {
|
||||
const event = new CreditsUsedEvent('gemini-3-pro-preview', 10, 490);
|
||||
expect(event['event.name']).toBe('credits_used');
|
||||
expect(event.credits_consumed).toBe(10);
|
||||
expect(event.credits_remaining).toBe(490);
|
||||
});
|
||||
|
||||
it('should produce correct OpenTelemetry attributes', () => {
|
||||
const event = new CreditsUsedEvent('gemini-3-pro-preview', 10, 490);
|
||||
const attrs = event.toOpenTelemetryAttributes(fakeConfig);
|
||||
expect(attrs['event.name']).toBe(EVENT_CREDITS_USED);
|
||||
expect(attrs['credits_consumed']).toBe(10);
|
||||
expect(attrs['credits_remaining']).toBe(490);
|
||||
});
|
||||
|
||||
it('should produce a human-readable log body', () => {
|
||||
const event = new CreditsUsedEvent('gemini-3-pro-preview', 10, 490);
|
||||
const body = event.toLogBody();
|
||||
expect(body).toContain('10');
|
||||
expect(body).toContain('490');
|
||||
expect(body).toContain('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyUpdatedEvent', () => {
|
||||
it('should construct with correct properties', () => {
|
||||
const event = new ApiKeyUpdatedEvent('google_login', 'api_key');
|
||||
expect(event['event.name']).toBe('api_key_updated');
|
||||
expect(event.previous_auth_type).toBe('google_login');
|
||||
expect(event.new_auth_type).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should produce correct OpenTelemetry attributes', () => {
|
||||
const event = new ApiKeyUpdatedEvent('google_login', 'api_key');
|
||||
const attrs = event.toOpenTelemetryAttributes(fakeConfig);
|
||||
expect(attrs['event.name']).toBe(EVENT_API_KEY_UPDATED);
|
||||
expect(attrs['previous_auth_type']).toBe('google_login');
|
||||
expect(attrs['new_auth_type']).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should produce a human-readable log body', () => {
|
||||
const event = new ApiKeyUpdatedEvent('google_login', 'api_key');
|
||||
const body = event.toLogBody();
|
||||
expect(body).toContain('google_login');
|
||||
expect(body).toContain('api_key');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { LogAttributes } from '@opentelemetry/api-logs';
|
||||
import type { BaseTelemetryEvent } from './types.js';
|
||||
import { getCommonAttributes } from './telemetryAttributes.js';
|
||||
import type { OverageStrategy } from '../billing/billing.js';
|
||||
|
||||
/** Overage menu option that can be selected by the user */
|
||||
export type OverageOption =
|
||||
| 'use_credits'
|
||||
| 'use_fallback'
|
||||
| 'manage'
|
||||
| 'stop'
|
||||
| 'get_credits';
|
||||
|
||||
// ============================================================================
|
||||
// Event: Overage Menu Shown
|
||||
// ============================================================================
|
||||
|
||||
export const EVENT_OVERAGE_MENU_SHOWN = 'gemini_cli.overage_menu_shown';
|
||||
|
||||
export class OverageMenuShownEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'overage_menu_shown';
|
||||
'event.timestamp': string;
|
||||
model: string;
|
||||
credit_balance: number;
|
||||
overage_strategy: OverageStrategy;
|
||||
|
||||
constructor(
|
||||
model: string,
|
||||
creditBalance: number,
|
||||
overageStrategy: OverageStrategy,
|
||||
) {
|
||||
this['event.name'] = 'overage_menu_shown';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.model = model;
|
||||
this.credit_balance = creditBalance;
|
||||
this.overage_strategy = overageStrategy;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_OVERAGE_MENU_SHOWN,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
model: this.model,
|
||||
credit_balance: this.credit_balance,
|
||||
overage_strategy: this.overage_strategy,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Overage menu shown for model ${this.model} with ${this.credit_balance} credits available.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event: Overage Option Selected
|
||||
// ============================================================================
|
||||
|
||||
export const EVENT_OVERAGE_OPTION_SELECTED =
|
||||
'gemini_cli.overage_option_selected';
|
||||
|
||||
export class OverageOptionSelectedEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'overage_option_selected';
|
||||
'event.timestamp': string;
|
||||
model: string;
|
||||
selected_option: OverageOption;
|
||||
credit_balance: number;
|
||||
|
||||
constructor(
|
||||
model: string,
|
||||
selectedOption: OverageOption,
|
||||
creditBalance: number,
|
||||
) {
|
||||
this['event.name'] = 'overage_option_selected';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.model = model;
|
||||
this.selected_option = selectedOption;
|
||||
this.credit_balance = creditBalance;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_OVERAGE_OPTION_SELECTED,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
model: this.model,
|
||||
selected_option: this.selected_option,
|
||||
credit_balance: this.credit_balance,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Overage option '${this.selected_option}' selected for model ${this.model}.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event: Empty Wallet Menu Shown
|
||||
// ============================================================================
|
||||
|
||||
export const EVENT_EMPTY_WALLET_MENU_SHOWN =
|
||||
'gemini_cli.empty_wallet_menu_shown';
|
||||
|
||||
export class EmptyWalletMenuShownEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'empty_wallet_menu_shown';
|
||||
'event.timestamp': string;
|
||||
model: string;
|
||||
|
||||
constructor(model: string) {
|
||||
this['event.name'] = 'empty_wallet_menu_shown';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_EMPTY_WALLET_MENU_SHOWN,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
model: this.model,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Empty wallet menu shown for model ${this.model}.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event: Credit Purchase Click
|
||||
// ============================================================================
|
||||
|
||||
export const EVENT_CREDIT_PURCHASE_CLICK = 'gemini_cli.credit_purchase_click';
|
||||
|
||||
export class CreditPurchaseClickEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'credit_purchase_click';
|
||||
'event.timestamp': string;
|
||||
source: 'overage_menu' | 'empty_wallet_menu' | 'manage';
|
||||
model: string;
|
||||
|
||||
constructor(
|
||||
source: 'overage_menu' | 'empty_wallet_menu' | 'manage',
|
||||
model: string,
|
||||
) {
|
||||
this['event.name'] = 'credit_purchase_click';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.source = source;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_CREDIT_PURCHASE_CLICK,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
source: this.source,
|
||||
model: this.model,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Credit purchase clicked from ${this.source} for model ${this.model}.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event: Credits Used
|
||||
// ============================================================================
|
||||
|
||||
export const EVENT_CREDITS_USED = 'gemini_cli.credits_used';
|
||||
|
||||
export class CreditsUsedEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'credits_used';
|
||||
'event.timestamp': string;
|
||||
model: string;
|
||||
credits_consumed: number;
|
||||
credits_remaining: number;
|
||||
|
||||
constructor(
|
||||
model: string,
|
||||
creditsConsumed: number,
|
||||
creditsRemaining: number,
|
||||
) {
|
||||
this['event.name'] = 'credits_used';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.model = model;
|
||||
this.credits_consumed = creditsConsumed;
|
||||
this.credits_remaining = creditsRemaining;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_CREDITS_USED,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
model: this.model,
|
||||
credits_consumed: this.credits_consumed,
|
||||
credits_remaining: this.credits_remaining,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `${this.credits_consumed} credits consumed for model ${this.model}. ${this.credits_remaining} remaining.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event: API Key Updated (Auth Type Changed)
|
||||
// ============================================================================
|
||||
|
||||
export const EVENT_API_KEY_UPDATED = 'gemini_cli.api_key_updated';
|
||||
|
||||
export class ApiKeyUpdatedEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'api_key_updated';
|
||||
'event.timestamp': string;
|
||||
previous_auth_type: string;
|
||||
new_auth_type: string;
|
||||
|
||||
constructor(previousAuthType: string, newAuthType: string) {
|
||||
this['event.name'] = 'api_key_updated';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.previous_auth_type = previousAuthType;
|
||||
this.new_auth_type = newAuthType;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_API_KEY_UPDATED,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
previous_auth_type: this.previous_auth_type,
|
||||
new_auth_type: this.new_auth_type,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Auth type changed from ${this.previous_auth_type} to ${this.new_auth_type}.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Union type of all billing-related telemetry events */
|
||||
export type BillingTelemetryEvent =
|
||||
| OverageMenuShownEvent
|
||||
| OverageOptionSelectedEvent
|
||||
| EmptyWalletMenuShownEvent
|
||||
| CreditPurchaseClickEvent
|
||||
| CreditsUsedEvent
|
||||
| ApiKeyUpdatedEvent;
|
||||
@@ -39,6 +39,7 @@ describe('conseca-logger', () => {
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
|
||||
} as unknown as Config;
|
||||
|
||||
mockLogger = {
|
||||
|
||||
@@ -77,6 +77,7 @@ export type { TelemetryEvent } from './types.js';
|
||||
export { SpanStatusCode, ValueType } from '@opentelemetry/api';
|
||||
export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
||||
export * from './uiTelemetry.js';
|
||||
export * from './billingEvents.js';
|
||||
export {
|
||||
MemoryMonitor,
|
||||
initializeMemoryMonitor,
|
||||
@@ -145,6 +146,9 @@ export {
|
||||
GenAiOperationName,
|
||||
GenAiProviderName,
|
||||
GenAiTokenType,
|
||||
// Billing metrics functions
|
||||
recordOverageOptionSelected,
|
||||
recordCreditPurchaseClick,
|
||||
} from './metrics.js';
|
||||
export { runInDevTraceSpan, type SpanMetadata } from './trace.js';
|
||||
export { startupProfiler, StartupProfiler } from './startupProfiler.js';
|
||||
|
||||
@@ -280,6 +280,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
it('should log a user prompt', () => {
|
||||
@@ -319,6 +320,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
const event = new UserPromptEvent(
|
||||
11,
|
||||
@@ -356,7 +358,8 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
} as Config;
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
const mockMetrics = {
|
||||
recordApiResponseMetrics: vi.fn(),
|
||||
@@ -558,7 +561,8 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
} as Config;
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
const mockMetrics = {
|
||||
recordApiResponseMetrics: vi.fn(),
|
||||
@@ -996,6 +1000,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
it('should log flash fallback event', () => {
|
||||
@@ -1025,6 +1030,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1121,7 +1127,8 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
} as Config;
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
const mockMetrics = {
|
||||
recordToolCallMetrics: vi.fn(),
|
||||
@@ -1741,7 +1748,8 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
} as Config;
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
const mockMetrics = {
|
||||
recordFileOperationMetric: vi.fn(),
|
||||
@@ -1803,6 +1811,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
it('should log a tool output truncated event', () => {
|
||||
@@ -1842,6 +1851,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2099,6 +2109,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2146,6 +2157,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2193,6 +2205,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2231,6 +2244,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2284,6 +2298,7 @@ describe('loggers', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2322,6 +2337,7 @@ describe('loggers', () => {
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -84,6 +84,7 @@ import type { UiEvent } from './uiTelemetry.js';
|
||||
import { uiTelemetryService } from './uiTelemetry.js';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { BillingTelemetryEvent } from './billingEvents.js';
|
||||
|
||||
export function logCliConfiguration(
|
||||
config: Config,
|
||||
@@ -827,3 +828,17 @@ export function logTokenStorageInitialization(
|
||||
recordTokenStorageInitialization(config, event);
|
||||
});
|
||||
}
|
||||
|
||||
export function logBillingEvent(
|
||||
config: Config,
|
||||
event: BillingTelemetryEvent,
|
||||
): void {
|
||||
bufferTelemetryEvent(() => {
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: event.toLogBody(),
|
||||
attributes: event.toOpenTelemetryAttributes(config),
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ const EVENT_HOOK_CALL_COUNT = 'gemini_cli.hook_call.count';
|
||||
const EVENT_HOOK_CALL_LATENCY = 'gemini_cli.hook_call.latency';
|
||||
const KEYCHAIN_AVAILABILITY_COUNT = 'gemini_cli.keychain.availability.count';
|
||||
const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count';
|
||||
const OVERAGE_OPTION_COUNT = 'gemini_cli.overage_option.count';
|
||||
const CREDIT_PURCHASE_COUNT = 'gemini_cli.credit_purchase.count';
|
||||
|
||||
// Agent Metrics
|
||||
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
|
||||
@@ -259,6 +261,26 @@ const COUNTER_DEFINITIONS = {
|
||||
forced: boolean;
|
||||
},
|
||||
},
|
||||
[OVERAGE_OPTION_COUNT]: {
|
||||
description: 'Counts overage option selections.',
|
||||
valueType: ValueType.INT,
|
||||
assign: (c: Counter) => (overageOptionCounter = c),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
attributes: {} as {
|
||||
selected_option: string;
|
||||
model: string;
|
||||
},
|
||||
},
|
||||
[CREDIT_PURCHASE_COUNT]: {
|
||||
description: 'Counts credit purchase link clicks.',
|
||||
valueType: ValueType.INT,
|
||||
assign: (c: Counter) => (creditPurchaseCounter = c),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
attributes: {} as {
|
||||
source: string;
|
||||
model: string;
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const HISTOGRAM_DEFINITIONS = {
|
||||
@@ -597,6 +619,8 @@ let hookCallCounter: Counter | undefined;
|
||||
let hookCallLatencyHistogram: Histogram | undefined;
|
||||
let keychainAvailabilityCounter: Counter | undefined;
|
||||
let tokenStorageTypeCounter: Counter | undefined;
|
||||
let overageOptionCounter: Counter | undefined;
|
||||
let creditPurchaseCounter: Counter | undefined;
|
||||
|
||||
// OpenTelemetry GenAI Semantic Convention Metrics
|
||||
let genAiClientTokenUsageHistogram: Histogram | undefined;
|
||||
@@ -1334,3 +1358,31 @@ export function recordTokenStorageInitialization(
|
||||
forced: event.forced,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a metric for an overage option selection.
|
||||
*/
|
||||
export function recordOverageOptionSelected(
|
||||
config: Config,
|
||||
attributes: MetricDefinitions[typeof OVERAGE_OPTION_COUNT]['attributes'],
|
||||
): void {
|
||||
if (!overageOptionCounter || !isMetricsInitialized) return;
|
||||
overageOptionCounter.add(1, {
|
||||
...baseMetricDefinition.getCommonAttributes(config),
|
||||
...attributes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a metric for a credit purchase link click.
|
||||
*/
|
||||
export function recordCreditPurchaseClick(
|
||||
config: Config,
|
||||
attributes: MetricDefinitions[typeof CREDIT_PURCHASE_COUNT]['attributes'],
|
||||
): void {
|
||||
if (!creditPurchaseCounter || !isMetricsInitialized) return;
|
||||
creditPurchaseCounter.add(1, {
|
||||
...baseMetricDefinition.getCommonAttributes(config),
|
||||
...attributes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function createMockConfig(logPromptsEnabled: boolean): Config {
|
||||
getModel: () => 'gemini-1.5-flash',
|
||||
isInteractive: () => true,
|
||||
getUserEmail: () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('Telemetry SDK', () => {
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
getExperimentsAsync: async () => undefined,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ const installationManager = new InstallationManager();
|
||||
export function getCommonAttributes(config: Config): Attributes {
|
||||
const email = userAccountManager.getCachedGoogleAccount();
|
||||
const experiments = config.getExperiments();
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
return {
|
||||
'session.id': config.getSessionId(),
|
||||
'installation.id': installationManager.getInstallationId(),
|
||||
interactive: config.isInteractive(),
|
||||
...(email && { 'user.email': email }),
|
||||
...(authType && { auth_type: authType }),
|
||||
...(experiments &&
|
||||
experiments.experimentIds.length > 0 && {
|
||||
'experiments.ids': experiments.experimentIds,
|
||||
|
||||
@@ -474,4 +474,98 @@ describe('McpClientManager', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('diagnostic reporting', () => {
|
||||
let coreEventsMock: typeof import('../utils/events.js').coreEvents;
|
||||
|
||||
beforeEach(async () => {
|
||||
const eventsModule = await import('../utils/events.js');
|
||||
coreEventsMock = eventsModule.coreEvents;
|
||||
vi.spyOn(coreEventsMock, 'emitFeedback').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should emit hint instead of full error when user has not interacted with MCP', () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
manager.emitDiagnostic(
|
||||
'error',
|
||||
'Something went wrong',
|
||||
new Error('boom'),
|
||||
);
|
||||
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'MCP issues detected. Run /mcp list for status.',
|
||||
);
|
||||
expect(coreEventsMock.emitFeedback).not.toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Something went wrong',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit full error when user has interacted with MCP', () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
manager.setUserInteractedWithMcp();
|
||||
manager.emitDiagnostic(
|
||||
'error',
|
||||
'Something went wrong',
|
||||
new Error('boom'),
|
||||
);
|
||||
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Something went wrong',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should still deduplicate diagnostic messages after user interaction', () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
manager.setUserInteractedWithMcp();
|
||||
|
||||
manager.emitDiagnostic('error', 'Same error');
|
||||
manager.emitDiagnostic('error', 'Same error');
|
||||
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should only show hint once per session', () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
|
||||
manager.emitDiagnostic('error', 'Error 1');
|
||||
manager.emitDiagnostic('error', 'Error 2');
|
||||
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(1);
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'MCP issues detected. Run /mcp list for status.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should capture last error for a server even when silenced', () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
|
||||
manager.emitDiagnostic(
|
||||
'error',
|
||||
'Error in server (test-server)',
|
||||
undefined,
|
||||
'test-server',
|
||||
);
|
||||
|
||||
expect(manager.getLastError('test-server')).toBe(
|
||||
'Error in server (test-server)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show previously deduplicated errors after interaction clears state', () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
|
||||
manager.emitDiagnostic('error', 'Same error');
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(1); // The hint
|
||||
|
||||
manager.setUserInteractedWithMcp();
|
||||
manager.emitDiagnostic('error', 'Same error');
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(2); // Now the actual error
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,28 @@ export class McpClientManager {
|
||||
extensionName: string;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* Track whether the user has explicitly interacted with MCP in this session
|
||||
* (e.g. by running an /mcp command).
|
||||
*/
|
||||
private userInteractedWithMcp: boolean = false;
|
||||
|
||||
/**
|
||||
* Track which MCP diagnostics have already been shown to the user this session
|
||||
* and at what verbosity level.
|
||||
*/
|
||||
private shownDiagnostics: Map<string, 'silent' | 'verbose'> = new Map();
|
||||
|
||||
/**
|
||||
* Track whether the MCP "hint" has been shown.
|
||||
*/
|
||||
private hintShown: boolean = false;
|
||||
|
||||
/**
|
||||
* Track the last error message for each server.
|
||||
*/
|
||||
private lastErrors: Map<string, string> = new Map();
|
||||
|
||||
constructor(
|
||||
clientVersion: string,
|
||||
toolRegistry: ToolRegistry,
|
||||
@@ -54,6 +76,69 @@ export class McpClientManager {
|
||||
this.eventEmitter = eventEmitter;
|
||||
}
|
||||
|
||||
setUserInteractedWithMcp() {
|
||||
this.userInteractedWithMcp = true;
|
||||
}
|
||||
|
||||
getLastError(serverName: string): string | undefined {
|
||||
return this.lastErrors.get(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an MCP diagnostic message, adhering to the user's intent and
|
||||
* deduplication rules.
|
||||
*/
|
||||
emitDiagnostic(
|
||||
severity: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
error?: unknown,
|
||||
serverName?: string,
|
||||
) {
|
||||
// Capture error for later display if it's an error/warning
|
||||
if (severity === 'error' || severity === 'warning') {
|
||||
if (serverName) {
|
||||
this.lastErrors.set(serverName, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const diagnosticKey = `${severity}:${message}`;
|
||||
const previousStatus = this.shownDiagnostics.get(diagnosticKey);
|
||||
|
||||
// If user has interacted, show verbosely unless already shown verbosely
|
||||
if (this.userInteractedWithMcp) {
|
||||
if (previousStatus === 'verbose') {
|
||||
debugLogger.debug(
|
||||
`Deduplicated verbose MCP diagnostic: ${diagnosticKey}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.shownDiagnostics.set(diagnosticKey, 'verbose');
|
||||
coreEvents.emitFeedback(severity, message, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// In silent mode, if it has been shown at all, skip
|
||||
if (previousStatus) {
|
||||
debugLogger.debug(`Deduplicated silent MCP diagnostic: ${diagnosticKey}`);
|
||||
return;
|
||||
}
|
||||
this.shownDiagnostics.set(diagnosticKey, 'silent');
|
||||
|
||||
// Otherwise, be less annoying
|
||||
debugLogger.log(`[MCP ${severity}] ${message}`, error);
|
||||
|
||||
if (severity === 'error' || severity === 'warning') {
|
||||
if (!this.hintShown) {
|
||||
this.hintShown = true;
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'MCP issues detected. Run /mcp list for status.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getBlockedMcpServers() {
|
||||
return this.blockedMcpServers;
|
||||
}
|
||||
@@ -253,7 +338,7 @@ export class McpClientManager {
|
||||
if (!isAuthenticationError(error)) {
|
||||
// Log the error but don't let a single failed server stop the others
|
||||
const errorMessage = getErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
this.emitDiagnostic(
|
||||
'error',
|
||||
`Error during discovery for MCP server '${name}': ${errorMessage}`,
|
||||
error,
|
||||
@@ -262,7 +347,7 @@ export class McpClientManager {
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
this.emitDiagnostic(
|
||||
'error',
|
||||
`Error initializing MCP server '${name}': ${errorMessage}`,
|
||||
error,
|
||||
@@ -391,7 +476,7 @@ export class McpClientManager {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
this.emitDiagnostic(
|
||||
'error',
|
||||
`Error stopping client '${name}':`,
|
||||
error,
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from './tools.js';
|
||||
import type { CallableTool, FunctionCall, Part } from '@google/genai';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { McpContext } from './mcp-client.js';
|
||||
|
||||
/**
|
||||
* The separator used to qualify MCP tool names with their server prefix.
|
||||
@@ -89,7 +89,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
readonly trust?: boolean,
|
||||
params: ToolParams = {},
|
||||
private readonly cliConfig?: Config,
|
||||
private readonly cliConfig?: McpContext,
|
||||
private readonly toolDescription?: string,
|
||||
private readonly toolParameterSchema?: unknown,
|
||||
toolAnnotationsData?: Record<string, unknown>,
|
||||
@@ -186,6 +186,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
this.cliConfig?.setUserInteractedWithMcp?.();
|
||||
const functionCalls: FunctionCall[] = [
|
||||
{
|
||||
name: this.serverToolName,
|
||||
@@ -268,7 +269,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
readonly trust?: boolean,
|
||||
isReadOnly?: boolean,
|
||||
nameOverride?: string,
|
||||
private readonly cliConfig?: Config,
|
||||
private readonly cliConfig?: McpContext,
|
||||
override readonly extensionName?: string,
|
||||
override readonly extensionId?: string,
|
||||
private readonly _toolAnnotations?: Record<string, unknown>,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expand } from 'dotenv-expand';
|
||||
import { expand, type DotenvExpandOutput } from 'dotenv-expand';
|
||||
|
||||
/**
|
||||
* Expands environment variables in a string using the provided environment record.
|
||||
@@ -45,7 +45,7 @@ export function expandEnvVars(
|
||||
}
|
||||
}
|
||||
|
||||
const result = expand({
|
||||
const result: DotenvExpandOutput = expand({
|
||||
parsed: { [dummyKey]: processedStr },
|
||||
processEnv,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
toFriendlyError,
|
||||
BadRequestError,
|
||||
ForbiddenError,
|
||||
AccountSuspendedError,
|
||||
getErrorMessage,
|
||||
getErrorType,
|
||||
FatalAuthenticationError,
|
||||
@@ -127,9 +128,86 @@ describe('toFriendlyError', () => {
|
||||
};
|
||||
const result = toFriendlyError(error);
|
||||
expect(result).toBeInstanceOf(ForbiddenError);
|
||||
expect(result).not.toBeInstanceOf(AccountSuspendedError);
|
||||
expect((result as ForbiddenError).message).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('should return AccountSuspendedError for 403 with TOS_VIOLATION reason in details', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
code: 403,
|
||||
message:
|
||||
'This service has been disabled in this account for violation of Terms of Service.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'TOS_VIOLATION',
|
||||
domain: 'example.googleapis.com',
|
||||
metadata: {
|
||||
uiMessage: 'true',
|
||||
appeal_url_link_text: 'Appeal Here',
|
||||
appeal_url: 'https://example.com/appeal',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = toFriendlyError(error);
|
||||
expect(result).toBeInstanceOf(AccountSuspendedError);
|
||||
expect(result).toBeInstanceOf(ForbiddenError);
|
||||
const suspended = result as AccountSuspendedError;
|
||||
expect(suspended.message).toBe(
|
||||
'This service has been disabled in this account for violation of Terms of Service.',
|
||||
);
|
||||
expect(suspended.appealUrl).toBe('https://example.com/appeal');
|
||||
expect(suspended.appealLinkText).toBe('Appeal Here');
|
||||
});
|
||||
|
||||
it('should return ForbiddenError for 403 with violation message but no TOS_VIOLATION detail', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
code: 403,
|
||||
message:
|
||||
'This service has been disabled in this account for violation of Terms of Service.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = toFriendlyError(error);
|
||||
expect(result).toBeInstanceOf(ForbiddenError);
|
||||
expect(result).not.toBeInstanceOf(AccountSuspendedError);
|
||||
});
|
||||
|
||||
it('should return ForbiddenError for 403 with non-TOS_VIOLATION detail', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
code: 403,
|
||||
message: 'Forbidden',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'ACCESS_DENIED',
|
||||
domain: 'googleapis.com',
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = toFriendlyError(error);
|
||||
expect(result).toBeInstanceOf(ForbiddenError);
|
||||
expect(result).not.toBeInstanceOf(AccountSuspendedError);
|
||||
});
|
||||
|
||||
it('should parse stringified JSON data', () => {
|
||||
const error = {
|
||||
response: {
|
||||
@@ -236,6 +314,9 @@ describe('getErrorType', () => {
|
||||
'FatalCancellationError',
|
||||
);
|
||||
expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError');
|
||||
expect(getErrorType(new AccountSuspendedError('test'))).toBe(
|
||||
'AccountSuspendedError',
|
||||
);
|
||||
expect(getErrorType(new UnauthorizedError('test'))).toBe(
|
||||
'UnauthorizedError',
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { parseGoogleApiError, type ErrorInfo } from './googleErrors.js';
|
||||
|
||||
interface GaxiosError {
|
||||
response?: {
|
||||
data?: unknown;
|
||||
@@ -107,6 +109,17 @@ export class CanceledError extends Error {
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {}
|
||||
export class AccountSuspendedError extends ForbiddenError {
|
||||
readonly appealUrl?: string;
|
||||
readonly appealLinkText?: string;
|
||||
|
||||
constructor(message: string, metadata?: Record<string, string>) {
|
||||
super(message);
|
||||
this.name = 'AccountSuspendedError';
|
||||
this.appealUrl = metadata?.['appeal_url'];
|
||||
this.appealLinkText = metadata?.['appeal_url_link_text'];
|
||||
}
|
||||
}
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class BadRequestError extends Error {}
|
||||
|
||||
@@ -157,6 +170,24 @@ function isResponseData(data: unknown): data is ResponseData {
|
||||
}
|
||||
|
||||
export function toFriendlyError(error: unknown): unknown {
|
||||
// First, try structured parsing for TOS_VIOLATION detection.
|
||||
const googleApiError = parseGoogleApiError(error);
|
||||
if (googleApiError && googleApiError.code === 403) {
|
||||
const tosDetail = googleApiError.details.find(
|
||||
(d): d is ErrorInfo =>
|
||||
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo' &&
|
||||
'reason' in d &&
|
||||
d.reason === 'TOS_VIOLATION',
|
||||
);
|
||||
if (tosDetail) {
|
||||
return new AccountSuspendedError(
|
||||
googleApiError.message,
|
||||
tosDetail.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to basic Gaxios error parsing for other HTTP errors.
|
||||
if (isGaxiosError(error)) {
|
||||
const data = parseResponseData(error);
|
||||
if (data && data.error && data.error.message && data.error.code) {
|
||||
@@ -166,9 +197,6 @@ export function toFriendlyError(error: unknown): unknown {
|
||||
case 401:
|
||||
return new UnauthorizedError(data.error.message);
|
||||
case 403:
|
||||
// It's import to pass the message here since it might
|
||||
// explain the cause like "the cloud project you're
|
||||
// using doesn't have code assist enabled".
|
||||
return new ForbiddenError(data.error.message);
|
||||
default:
|
||||
}
|
||||
@@ -177,6 +205,13 @@ export function toFriendlyError(error: unknown): unknown {
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isAccountSuspendedError(
|
||||
error: unknown,
|
||||
): AccountSuspendedError | null {
|
||||
const friendly = toFriendlyError(error);
|
||||
return friendly instanceof AccountSuspendedError ? friendly : null;
|
||||
}
|
||||
|
||||
function parseResponseData(error: GaxiosError): ResponseData | undefined {
|
||||
let data = error.response?.data;
|
||||
// Inexplicably, Gaxios sometimes doesn't JSONify the response data.
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
export interface ErrorInfo {
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo';
|
||||
reason: string;
|
||||
domain: string;
|
||||
metadata: { [key: string]: string };
|
||||
domain?: string;
|
||||
metadata?: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface RetryInfo {
|
||||
|
||||
@@ -313,6 +313,26 @@ describe('classifyGoogleError', () => {
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for INSUFFICIENT_G1_CREDITS_BALANCE without domain', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'Resource has been exhausted (e.g. check quota).',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'INSUFFICIENT_G1_CREDITS_BALANCE',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
expect((result as TerminalQuotaError).isInsufficientCredits).toBe(true);
|
||||
expect((result as TerminalQuotaError).reason).toBe(
|
||||
'INSUFFICIENT_G1_CREDITS_BALANCE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize daily limit over retry info', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
|
||||
@@ -19,17 +19,24 @@ import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
*/
|
||||
export class TerminalQuotaError extends Error {
|
||||
retryDelayMs?: number;
|
||||
reason?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
override readonly cause: GoogleApiError,
|
||||
retryDelaySeconds?: number,
|
||||
reason?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TerminalQuotaError';
|
||||
this.retryDelayMs = retryDelaySeconds
|
||||
? retryDelaySeconds * 1000
|
||||
: undefined;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
get isInsufficientCredits(): boolean {
|
||||
return this.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +128,7 @@ function classifyValidationRequiredError(
|
||||
}
|
||||
|
||||
if (
|
||||
!errorInfo.domain ||
|
||||
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
|
||||
errorInfo.reason !== 'VALIDATION_REQUIRED'
|
||||
) {
|
||||
@@ -293,6 +301,16 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
|
||||
if (errorInfo) {
|
||||
// INSUFFICIENT_G1_CREDITS_BALANCE is always terminal, regardless of domain
|
||||
if (errorInfo.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE') {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
|
||||
// New Cloud Code API quota handling
|
||||
if (errorInfo.domain) {
|
||||
const validDomains = [
|
||||
@@ -313,6 +331,7 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
`${googleApiError.message}`,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user