mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
refactor: P6 god-file splits (quota-fetcher + quota-fetcher-gemini-cli; 4 deferred)
Epic P6 (partial: 2 of 6 targets). These two files have strong existing per-provider test coverage, so the characterization-first requirement is met by the safety net; both are split test-backed. - src/cliproxy/quota/quota-fetcher.ts (1106 -> barrel 29 + 9 submodules, max 254 LOC): status-classifier (the clean extract), http-client, auth-file-reader, project-lookup, available-models-fetcher, account-quota-fetcher, all-accounts-fetcher, types, constants. - src/cliproxy/quota/quota-fetcher-gemini-cli.ts (1179 -> barrel 27 + 11 submodules, max 334 LOC): token-parsing, auth-file-discovery, supplementary-metadata, error-parsing, bucket-building, managed-request, shared-utils, types, constants, index. Public API preserved verbatim (incl. __testExports). All P3 structured logging preserved through the splits. Regenerated eslint-rules/throw-error-baseline.json (throws moved to new module paths). Remaining P6 targets (characterization-first, deferred): oauth-handler.ts, cursor-executor.ts, tool-sanitization-proxy.ts, + 1. Each needs dedicated characterization-test work before splitting; tracked in the epic plan. Metric: files > 400 LOC 91 -> 89. validate + validate:ci-parity green.
This commit is contained in:
1 parent
2f94f35ec3
commit
aecc1f7217
22 files changed
+2839
-2265
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Auth file discovery for the Gemini CLI quota fetcher (direct-path credentials).
|
||||
*
|
||||
* Locates and parses the on-disk Gemini CLI auth file for a given account,
|
||||
* supporting both the legacy `gemini-<sanitized>.json` filename and the newer
|
||||
* `<email>-gen-lang-client-<projectId>.json` pattern. Scans both the active
|
||||
* auth directory and the paused-account directory.
|
||||
*
|
||||
* Returns the access token, project ID, expiry, and expired flag. The live
|
||||
* token is returned only so the caller can place it in an Authorization header;
|
||||
* it is never logged by this module or its callers.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getAuthDir } from '../../config/config-generator';
|
||||
import { getPausedDir } from '../../accounts/account-manager';
|
||||
import { isTokenExpired } from '../../auth/auth-utils';
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { isGeminiAuthFile } from './managed-request';
|
||||
import { extractAccessToken, extractExpiry, resolveGeminiCliProjectId } from './token-parsing';
|
||||
import type { GeminiCliAuthData } from './types';
|
||||
|
||||
/**
|
||||
* Read auth data from a Gemini CLI auth file on disk.
|
||||
*
|
||||
* Resolution order per auth directory:
|
||||
* 1. Exact legacy match: `gemini-<sanitized-account>.json`
|
||||
* 2. Directory scan for files matching {@link isGeminiAuthFile}, filtered
|
||||
* by account email/filename and Gemini type.
|
||||
*
|
||||
* Scans both the active auth dir and the paused-account dir. Returns null if
|
||||
* no usable auth file (with an access token) is found.
|
||||
*/
|
||||
export function readGeminiCliAuthData(accountId: string): GeminiCliAuthData | null {
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const expectedFiles = [
|
||||
`gemini-${sanitizedId}.json`, // Legacy format
|
||||
`${accountId}-gen-lang-client-`, // New format prefix (partial match)
|
||||
];
|
||||
|
||||
for (const authDir of authDirs) {
|
||||
if (!fs.existsSync(authDir)) continue;
|
||||
|
||||
// Try exact legacy match first
|
||||
const legacyPath = path.join(authDir, expectedFiles[0]);
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(legacyPath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
const accessToken = extractAccessToken(data);
|
||||
if (accessToken) {
|
||||
const projectId =
|
||||
typeof data.project_id === 'string'
|
||||
? data.project_id
|
||||
: resolveGeminiCliProjectId(String(data.account || ''));
|
||||
const expiry = extractExpiry(data);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
projectId,
|
||||
isExpired: isTokenExpired(expiry ?? undefined),
|
||||
expiresAt: expiry,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Continue to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Scan directory for matching files
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (!isGeminiAuthFile(file)) continue;
|
||||
|
||||
const candidatePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
// Check if this file matches our account
|
||||
const fileEmail = typeof data.email === 'string' ? data.email : null;
|
||||
const fileType = typeof data.type === 'string' ? data.type : null;
|
||||
const matchesEmail = fileEmail === accountId;
|
||||
const matchesFilename = file.startsWith(`${accountId}-`) || file.includes(sanitizedId);
|
||||
const isGeminiType = fileType === 'gemini' || fileType === 'gemini-cli';
|
||||
|
||||
// Must match account AND be gemini type (or legacy gemini- prefix)
|
||||
if ((matchesEmail || matchesFilename) && (isGeminiType || file.startsWith('gemini-'))) {
|
||||
const accessToken = extractAccessToken(data);
|
||||
if (accessToken) {
|
||||
const projectId =
|
||||
typeof data.project_id === 'string'
|
||||
? data.project_id
|
||||
: resolveGeminiCliProjectId(String(data.account || ''));
|
||||
const expiry = extractExpiry(data);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
projectId,
|
||||
isExpired: isTokenExpired(expiry ?? undefined),
|
||||
expiresAt: expiry,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Bucket building for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Translates raw upstream quota buckets (snake_case and camelCase tolerant)
|
||||
* into the normalized {@link GeminiCliBucket} array grouped by model series
|
||||
* and token type. Delegates the grouping to the shared
|
||||
* `gemini-cli-quota-normalizer` so the grouping rules stay in one place.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildGeminiCliBucketsFromParsedBuckets,
|
||||
type GeminiCliParsedBucket,
|
||||
} from '../gemini-cli-quota-normalizer';
|
||||
import type { GeminiCliBucket } from '../quota-types';
|
||||
import type { RawGeminiCliBucket } from './types';
|
||||
import { normalizeNumberValue, normalizeStringValue } from './shared-utils';
|
||||
|
||||
/**
|
||||
* Build a {@link GeminiCliBucket} array from raw upstream quota buckets.
|
||||
*
|
||||
* Each raw bucket is normalized into a {@link GeminiCliParsedBucket}:
|
||||
* - skips buckets with no resolvable model id
|
||||
* - coalesces remaining_fraction / remaining_amount / reset_time across
|
||||
* naming variants, with a fallback of `1` (full) when none are present
|
||||
* but a reset time or non-positive amount implies exhaustion
|
||||
* Then delegates to {@link buildGeminiCliBucketsFromParsedBuckets} for the
|
||||
* model-series and token-type grouping.
|
||||
*/
|
||||
export function buildGeminiCliBuckets(rawBuckets: RawGeminiCliBucket[]): GeminiCliBucket[] {
|
||||
const parsedBuckets = rawBuckets
|
||||
.map((bucket): GeminiCliParsedBucket | null => {
|
||||
const modelId = normalizeStringValue(bucket.model_id ?? bucket.modelId);
|
||||
if (!modelId) return null;
|
||||
|
||||
const tokenType = normalizeStringValue(bucket.token_type ?? bucket.tokenType);
|
||||
const remainingFractionRaw = normalizeNumberValue(
|
||||
bucket.remaining_fraction ?? bucket.remainingFraction
|
||||
);
|
||||
const remainingAmount = normalizeNumberValue(
|
||||
bucket.remaining_amount ?? bucket.remainingAmount
|
||||
);
|
||||
const resetTime = normalizeStringValue(bucket.reset_time ?? bucket.resetTime);
|
||||
|
||||
let fallbackFraction: number | null = null;
|
||||
if (remainingAmount !== null) {
|
||||
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||
} else if (resetTime) {
|
||||
fallbackFraction = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
modelId,
|
||||
tokenType,
|
||||
remainingFraction: remainingFractionRaw ?? fallbackFraction ?? 1,
|
||||
remainingAmount,
|
||||
resetTime,
|
||||
};
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
|
||||
return buildGeminiCliBucketsFromParsedBuckets(parsedBuckets);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Constants for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Google Cloud Code API endpoints, error-detail sanitization limits, and
|
||||
* upstream request timeouts. Extracted verbatim from the original god file;
|
||||
* do not change values without coordinating with callers and tests.
|
||||
*/
|
||||
|
||||
/** Google Cloud Code internal API base URL. */
|
||||
export const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||
|
||||
/** Google Cloud Code API version path segment. */
|
||||
export const GEMINI_CLI_API_VERSION = 'v1internal';
|
||||
|
||||
/** retrieveUserQuota endpoint - returns bucket-based model quotas. */
|
||||
export const GEMINI_CLI_QUOTA_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`;
|
||||
|
||||
/** loadCodeAssist endpoint - returns tier/credit metadata. */
|
||||
export const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:loadCodeAssist`;
|
||||
|
||||
/** Max characters retained from a sanitized upstream error detail. */
|
||||
export const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
|
||||
|
||||
/** Suffix appended when an error detail is truncated. */
|
||||
export const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
|
||||
|
||||
/** Credit type identifying Google One AI (paid tier) credits. */
|
||||
export const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
||||
|
||||
/** Timeout for the primary (preferred) management API attempt, in ms. */
|
||||
export const MANAGEMENT_API_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Timeout for the secondary / fallback upstream request, in ms. */
|
||||
export const SECONDARY_REQUEST_TIMEOUT_MS = 2000;
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Error parsing and failure-result builders for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Translates non-200 upstream responses into structured {@link GeminiCliQuotaResult}
|
||||
* failure payloads with sanitized error details, recovery hints, and provider
|
||||
* entitlement evidence. Token values in error bodies are always redacted before
|
||||
* being surfaced (see {@link sanitizeGeminiCliErrorDetail}).
|
||||
*/
|
||||
|
||||
import {
|
||||
buildProviderEntitlementEvidence,
|
||||
isModelCapacityExhausted,
|
||||
} from '../../auth/provider-entitlement-evidence';
|
||||
import type {
|
||||
GeminiCliFailureResultOptions,
|
||||
GeminiCliQuotaResult,
|
||||
ParsedGeminiCliErrorBody,
|
||||
} from './types';
|
||||
import {
|
||||
GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH,
|
||||
GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* Build a structured failure {@link GeminiCliQuotaResult} with empty buckets.
|
||||
* Centralizes the common failure shape so each HTTP-status branch only needs
|
||||
* to supply its specific error/hint/entitlement fields.
|
||||
*/
|
||||
export function buildGeminiCliFailureResult(
|
||||
accountId: string,
|
||||
projectId: string | null,
|
||||
options: GeminiCliFailureResultOptions
|
||||
): GeminiCliQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
buckets: [],
|
||||
projectId,
|
||||
tierLabel: null,
|
||||
tierId: null,
|
||||
creditBalance: null,
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
error: options.error,
|
||||
httpStatus: options.httpStatus,
|
||||
errorCode: options.errorCode,
|
||||
errorDetail: options.errorDetail,
|
||||
actionHint: options.actionHint,
|
||||
retryable: options.retryable,
|
||||
needsReauth: options.needsReauth,
|
||||
isForbidden: options.isForbidden,
|
||||
entitlement: options.entitlement,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an upstream error body for safe inclusion in a quota result.
|
||||
*
|
||||
* - Collapses HTML responses to a placeholder (never leaks provider HTML).
|
||||
* - Redacts common token/credential/secret field names and `Bearer <token>`.
|
||||
* - Collapses internal whitespace to single spaces.
|
||||
* - Truncates to {@link GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH} with a sentinel suffix.
|
||||
*
|
||||
* Returns undefined for empty input. Token values are never preserved.
|
||||
*/
|
||||
export function sanitizeGeminiCliErrorDetail(bodyText: string): string | undefined {
|
||||
const trimmed = bodyText.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.test(trimmed)) {
|
||||
return '[HTML error response omitted]';
|
||||
}
|
||||
|
||||
let sanitized = trimmed
|
||||
.replace(
|
||||
/"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token)"\s*:\s*"[^"]*"/gi,
|
||||
'"$1":"[redacted]"'
|
||||
)
|
||||
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
if (sanitized.length > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) {
|
||||
sanitized = `${sanitized.slice(
|
||||
0,
|
||||
GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX.length
|
||||
)}${GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract the first non-empty message-like field from a nested
|
||||
* error `details` array/object. Looks for `message`, `localizedMessage`,
|
||||
* `description`, `reason`, and `error` keys at any level.
|
||||
*/
|
||||
export function extractGeminiCliNestedMessage(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const nested = extractGeminiCliNestedMessage(entry);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const directMessage = [
|
||||
record.message,
|
||||
record.localizedMessage,
|
||||
record.description,
|
||||
record.reason,
|
||||
record.error,
|
||||
].find(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0
|
||||
);
|
||||
if (directMessage) {
|
||||
return directMessage;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an upstream error body into a structured {@link ParsedGeminiCliErrorBody}.
|
||||
*
|
||||
* Extracts a top-level code/status, a message (looking inside `error` objects
|
||||
* and nested `details`), and a sanitized error detail. Non-JSON bodies fall
|
||||
* back to the raw (sanitized) trimmed text as the message. HTML bodies surface
|
||||
* only as the sanitized detail placeholder, never as the message.
|
||||
*/
|
||||
export function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody {
|
||||
const trimmed = bodyText.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitizedDetail = sanitizeGeminiCliErrorDetail(trimmed);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const topLevelMessage = [parsed.message, parsed.error].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
);
|
||||
const topLevelCode = [parsed.code, parsed.status].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
);
|
||||
|
||||
if (parsed.error && typeof parsed.error === 'object') {
|
||||
const error = parsed.error as Record<string, unknown>;
|
||||
return {
|
||||
errorCode:
|
||||
[error.status, error.code, topLevelCode].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
) || undefined,
|
||||
errorDetail: sanitizedDetail,
|
||||
message:
|
||||
[
|
||||
error.message,
|
||||
error.error,
|
||||
extractGeminiCliNestedMessage(error.details),
|
||||
topLevelMessage,
|
||||
].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: topLevelCode,
|
||||
errorDetail: sanitizedDetail,
|
||||
message:
|
||||
[topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
) || undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorDetail: sanitizedDetail,
|
||||
message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user-facing recovery hint for a 403 (forbidden) upstream response.
|
||||
* Inspects the parsed message/detail for verification, project, or generic
|
||||
* access signals and returns the matching recovery instruction.
|
||||
*/
|
||||
export function buildGeminiCliForbiddenActionHint(parsed: ParsedGeminiCliErrorBody): string {
|
||||
const combined = `${parsed.message || ''} ${parsed.errorDetail || ''}`.toLowerCase();
|
||||
if (combined.includes('verify') || combined.includes('verification')) {
|
||||
return 'Complete the Google account verification mentioned above, then retry quota refresh.';
|
||||
}
|
||||
if (combined.includes('project')) {
|
||||
return 'Confirm this Google project still has Gemini CLI quota access, then retry.';
|
||||
}
|
||||
return 'Check the Google account or workspace access shown above, then retry quota refresh.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structured failure result from an HTTP non-200 upstream response.
|
||||
*
|
||||
* Status-specific behavior:
|
||||
* - 401: marks the result as needsReauth (user must re-run `ccs gemini --auth`)
|
||||
* - 403: marks forbidden with runtime-inferred not_entitled evidence and a
|
||||
* context-aware action hint
|
||||
* - 429: distinguishes MODEL_CAPACITY_EXHAUSTED (entitled but capacity-stressed)
|
||||
* from generic rate limiting
|
||||
* - >=500: retryable provider-unavailable result
|
||||
* - other: generic non-retryable quota_request_failed
|
||||
*/
|
||||
export function buildGeminiCliHttpFailureResult(
|
||||
accountId: string,
|
||||
projectId: string | null,
|
||||
status: number,
|
||||
bodyText: string
|
||||
): GeminiCliQuotaResult {
|
||||
const parsed = parseGeminiCliErrorBody(bodyText);
|
||||
|
||||
if (status === 401) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Token expired or invalid',
|
||||
httpStatus: 401,
|
||||
errorCode: parsed.errorCode || 'reauth_required',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
needsReauth: true,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Quota access forbidden for this account',
|
||||
httpStatus: 403,
|
||||
errorCode: parsed.errorCode || 'quota_api_forbidden',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: buildGeminiCliForbiddenActionHint(parsed),
|
||||
isForbidden: true,
|
||||
retryable: false,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
if (isModelCapacityExhausted(parsed.message, parsed.errorDetail, parsed.errorCode)) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Model capacity exhausted for this account right now',
|
||||
httpStatus: 429,
|
||||
errorCode: 'capacity_exhausted',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint:
|
||||
'Retry later or switch to another Gemini model. This indicates temporary model capacity, not an authentication failure.',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'capacity_exhausted',
|
||||
notes: 'Upstream returned MODEL_CAPACITY_EXHAUSTED for this model.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Rate limited - try again later',
|
||||
httpStatus: 429,
|
||||
errorCode: parsed.errorCode || 'rate_limited',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Retry after a short delay.',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || `Gemini quota service unavailable (HTTP ${status})`,
|
||||
httpStatus: status,
|
||||
errorCode: parsed.errorCode || 'provider_unavailable',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Retry later. This looks like a temporary Google upstream problem.',
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || `Gemini quota request failed (HTTP ${status})`,
|
||||
httpStatus: status,
|
||||
errorCode: parsed.errorCode || 'quota_request_failed',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Inspect the upstream response details and retry if appropriate.',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Barrel for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Re-exports the original public surface of `quota-fetcher-gemini-cli.ts`
|
||||
* so the file at the original path can be reduced to a thin re-export
|
||||
* (preserving import paths and signatures). Submodules are private
|
||||
* implementation detail; only the symbols below are part of the contract.
|
||||
*/
|
||||
|
||||
// Public API
|
||||
export { fetchGeminiCliQuota, fetchAllGeminiCliQuotas } from './quota-fetcher';
|
||||
|
||||
// Exported helpers (also part of the public surface - used by tests and
|
||||
// the bucket/grouping normalization tests).
|
||||
export { resolveGeminiCliProjectId } from './token-parsing';
|
||||
export { buildGeminiCliBuckets } from './bucket-building';
|
||||
|
||||
// Test exports: keep the original `__testExports` bag shape stable so the
|
||||
// existing test suite (which destructures `__testExports`) keeps working.
|
||||
export {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
} from './error-parsing';
|
||||
|
||||
// Re-export `__testExports` as a single object to preserve the original
|
||||
// named-const export shape (`__testExports`).
|
||||
import {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
} from './error-parsing';
|
||||
|
||||
export const __testExports = {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Managed and direct HTTP request machinery for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Wraps the two upstream call paths used when fetching Gemini CLI quota and
|
||||
* supplementary metadata:
|
||||
* - managed: delegated through the CLIProxy management API (uses $TOKEN$
|
||||
* substitution so the local process never holds the live token)
|
||||
* - direct: bearer-token fetch against the Google Cloud Code endpoint
|
||||
*
|
||||
* The preferred path is configurable per call. On a 401 from the direct path,
|
||||
* the managed path is retried as a delegated-auth refresh fallback. A
|
||||
* `GeminiManagedAuthUnavailableError` is thrown when managed auth is required
|
||||
* but unreachable, so callers can surface a retryable failure result.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildManagementHeaders,
|
||||
buildProxyUrl,
|
||||
getProxyTarget,
|
||||
} from '../../proxy/proxy-target-resolver';
|
||||
import { mapExternalProviderName } from '../../provider-capabilities';
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { MANAGEMENT_API_TIMEOUT_MS, SECONDARY_REQUEST_TIMEOUT_MS } from './constants';
|
||||
import { getRemainingTimeoutMs, normalizeStringValue, safeParseJson } from './shared-utils';
|
||||
import type {
|
||||
ManagedGeminiAuthContext,
|
||||
ManagedGeminiAuthLookupResult,
|
||||
ManagedGeminiRequestResult,
|
||||
ManagedResponse,
|
||||
ManagementApiCallResponse,
|
||||
ManagementAuthFile,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Thrown when Gemini delegated auth refresh via the CLIProxy management API
|
||||
* is required but temporarily unreachable. Callers translate this into a
|
||||
* retryable failure result.
|
||||
*/
|
||||
export class GeminiManagedAuthUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('CLIProxy managed Gemini auth is temporarily unavailable');
|
||||
this.name = 'GeminiManagedAuthUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a fetch Response into the normalized {@link ManagedResponse} shape.
|
||||
* `viaManagement` marks whether the response came through the managed API so
|
||||
* downstream log messages can attribute the source correctly.
|
||||
*/
|
||||
export async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
): Promise<ManagedResponse> {
|
||||
const bodyText = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a filename matches the Gemini CLI auth file naming patterns.
|
||||
* Recognizes three patterns:
|
||||
* - legacy: gemini-*.json
|
||||
* - new: *-gen-lang-client-*.json
|
||||
* - email: contains "@" (verified against type inside the payload later)
|
||||
*/
|
||||
export function isGeminiAuthFile(filename: string): boolean {
|
||||
if (!filename.endsWith('.json')) return false;
|
||||
// Legacy pattern: gemini-email.json
|
||||
if (filename.startsWith('gemini-')) return true;
|
||||
// New pattern: email-gen-lang-client-projectId.json
|
||||
if (filename.includes('-gen-lang-client-')) return true;
|
||||
// Check if contains @ (email pattern) - will verify type inside
|
||||
if (filename.includes('@')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a management-API auth-file descriptor belongs to the
|
||||
* given Gemini account. Matches on provider/type normalized to "gemini",
|
||||
* then on email, filename, or sanitized email substring.
|
||||
*/
|
||||
export function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
|
||||
const rawProvider = normalizeStringValue(file.provider ?? file.type);
|
||||
if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const email = normalizeStringValue(file.email);
|
||||
const normalizedAccountId = accountId.trim().toLowerCase();
|
||||
if (email?.toLowerCase() === normalizedAccountId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = normalizeStringValue(file.name);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedFileName = normalizedName.toLowerCase();
|
||||
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
|
||||
return (
|
||||
normalizedFileName === `gemini-${sanitizedAccount}.json` ||
|
||||
normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) ||
|
||||
normalizedFileName.includes(sanitizedAccount)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the management-API auth index for a Gemini account.
|
||||
* Hits `/v0/management/auth-files` and matches the entry for this account.
|
||||
* Returns `{ unavailable: true }` if the management API is unreachable or
|
||||
* returns a non-OK response, so callers can fall back to direct auth.
|
||||
*/
|
||||
export async function findManagedGeminiAuthIndex(
|
||||
accountId: string,
|
||||
timeoutMs: number
|
||||
): Promise<ManagedGeminiAuthLookupResult> {
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { authIndex: null, unavailable: true };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: ManagementAuthFile[] };
|
||||
const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId));
|
||||
return { authIndex: match?.auth_index ?? null, unavailable: false };
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { authIndex: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the management auth index for a Gemini account, deduping concurrent
|
||||
* lookups for the same account via the shared {@link ManagedGeminiAuthContext}.
|
||||
* The first caller wins; subsequent callers await the same promise.
|
||||
*/
|
||||
export async function getManagedGeminiAuthIndex(
|
||||
accountId: string,
|
||||
timeoutMs: number,
|
||||
context?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedGeminiAuthLookupResult> {
|
||||
if (!context) {
|
||||
return await findManagedGeminiAuthIndex(accountId, timeoutMs);
|
||||
}
|
||||
|
||||
context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs);
|
||||
return await context.authIndexLookupPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single upstream request to the Gemini CLI API via the CLIProxy
|
||||
* management `/v0/management/api-call` endpoint. Uses `$TOKEN$` substitution
|
||||
* so the live token never leaves the management API. Returns
|
||||
* `{ unavailable: true }` if the management path is unreachable; returns
|
||||
* `{ response: null, unavailable: false }` if the request succeeded but no
|
||||
* matching auth file was found.
|
||||
*/
|
||||
export async function performManagedGeminiRequest(
|
||||
accountId: string,
|
||||
url: string,
|
||||
body: string,
|
||||
timeoutMs: number,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedGeminiRequestResult> {
|
||||
const deadlineMs = Date.now() + timeoutMs;
|
||||
const lookupResult = await getManagedGeminiAuthIndex(
|
||||
accountId,
|
||||
getRemainingTimeoutMs(deadlineMs),
|
||||
authContext
|
||||
);
|
||||
if (lookupResult.unavailable) {
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
|
||||
const authIndex = lookupResult.authIndex;
|
||||
if (authIndex === null || authIndex === undefined) {
|
||||
return { response: null, unavailable: false };
|
||||
}
|
||||
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs));
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
auth_index: authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: body,
|
||||
}),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
|
||||
const apiResponse = (await response.json()) as ManagementApiCallResponse;
|
||||
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
|
||||
return {
|
||||
response: {
|
||||
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement: true,
|
||||
},
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a Gemini CLI upstream request, preferring the managed path when
|
||||
* requested and falling back to direct bearer-token auth. On a 401 from the
|
||||
* direct path, retries via managed auth as a delegated-auth refresh; throws
|
||||
* {@link GeminiManagedAuthUnavailableError} if that retry is unreachable.
|
||||
*
|
||||
* @param accountId Account identifier (email), used for managed auth lookup.
|
||||
* @param accessToken Bearer token for the direct path. Never logged.
|
||||
* @param url Target Gemini CLI API URL.
|
||||
* @param body JSON request body string.
|
||||
* @param preferManagement When true, try the managed path first.
|
||||
* @param authContext Optional shared context to dedupe auth-index lookups.
|
||||
*/
|
||||
export async function performGeminiCliRequest(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
url: string,
|
||||
body: string,
|
||||
preferManagement = false,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedResponse> {
|
||||
let managementAttempted = false;
|
||||
let managementUnavailable = false;
|
||||
|
||||
if (preferManagement) {
|
||||
managementAttempted = true;
|
||||
const managedResult = await performManagedGeminiRequest(
|
||||
accountId,
|
||||
url,
|
||||
body,
|
||||
MANAGEMENT_API_TIMEOUT_MS,
|
||||
authContext
|
||||
);
|
||||
managementUnavailable = managedResult.unavailable;
|
||||
if (managedResult.response) {
|
||||
return managedResult.response;
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const directResult = await readManagedResponse(response, false);
|
||||
if (directResult.status !== 401) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
if (managementAttempted) {
|
||||
if (managementUnavailable) {
|
||||
throw new GeminiManagedAuthUnavailableError();
|
||||
}
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const managedResult = await performManagedGeminiRequest(
|
||||
accountId,
|
||||
url,
|
||||
body,
|
||||
SECONDARY_REQUEST_TIMEOUT_MS,
|
||||
authContext
|
||||
);
|
||||
if (managedResult.response) {
|
||||
return managedResult.response;
|
||||
}
|
||||
if (managedResult.unavailable) {
|
||||
throw new GeminiManagedAuthUnavailableError();
|
||||
}
|
||||
return directResult;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Top-level quota fetch orchestration for Gemini CLI accounts.
|
||||
*
|
||||
* Coordinates auth-file discovery, the managed/direct upstream quota request,
|
||||
* supplementary tier/credit metadata, and structured failure-result building.
|
||||
* Preserves the structured logging from the original god file (events:
|
||||
* gemini_cli.fetch_start, gemini_cli.auth_file_missing, gemini_cli.token_expired,
|
||||
* gemini_cli.missing_project_id, gemini_cli.api_status, gemini_cli.buckets_found,
|
||||
* gemini_cli.quota_fetch_error). Token values are never logged.
|
||||
*/
|
||||
|
||||
import { getProviderAccounts, setAccountTier } from '../../accounts/account-manager';
|
||||
import { getTokenExpiryTimestamp } from '../../auth/auth-utils';
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import type { GeminiCliQuotaResult } from '../quota-types';
|
||||
import { readGeminiCliAuthData } from './auth-file-discovery';
|
||||
import { buildGeminiCliBuckets } from './bucket-building';
|
||||
import { buildGeminiCliFailureResult, buildGeminiCliHttpFailureResult } from './error-parsing';
|
||||
import { GeminiManagedAuthUnavailableError, performGeminiCliRequest } from './managed-request';
|
||||
import { fetchGeminiCliSupplementary } from './supplementary-metadata';
|
||||
import { logger } from './shared-utils';
|
||||
import { GEMINI_CLI_QUOTA_URL } from './constants';
|
||||
import type { GeminiCliAuthData, GeminiCliQuotaResponse, ManagedGeminiAuthContext } from './types';
|
||||
|
||||
/**
|
||||
* Internal helper: fetch quota with already-validated auth data.
|
||||
*
|
||||
* Extracted to support the auto-refresh retry path: the caller resolves auth
|
||||
* data once (legacy file or managed), then this function performs the upstream
|
||||
* quota request and supplementary metadata fetch in parallel. On success it
|
||||
* persists the resolved tier back to the account via `setAccountTier`.
|
||||
*/
|
||||
export async function fetchWithAuthData(
|
||||
authData: GeminiCliAuthData,
|
||||
accountId: string,
|
||||
verbose: boolean
|
||||
): Promise<GeminiCliQuotaResult> {
|
||||
if (!authData.projectId) {
|
||||
const error = 'Cannot resolve project ID from auth file';
|
||||
if (verbose) {
|
||||
logger.error('gemini_cli.missing_project_id', `Error: ${error}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return buildGeminiCliFailureResult(accountId, null, {
|
||||
error,
|
||||
errorCode: 'missing_project_id',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account and recover the project ID.',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const authContext: ManagedGeminiAuthContext = {};
|
||||
const supplementaryPromise = fetchGeminiCliSupplementary(
|
||||
accountId,
|
||||
authData.accessToken,
|
||||
authData.projectId,
|
||||
verbose,
|
||||
authContext
|
||||
);
|
||||
const requestBody = JSON.stringify({ project: authData.projectId });
|
||||
|
||||
try {
|
||||
const response = await performGeminiCliRequest(
|
||||
accountId,
|
||||
authData.accessToken,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
requestBody,
|
||||
authData.isExpired,
|
||||
authContext
|
||||
);
|
||||
|
||||
if (verbose) {
|
||||
const source = response.viaManagement ? 'managed' : 'direct';
|
||||
logger.info(
|
||||
'gemini_cli.api_status',
|
||||
`Gemini CLI API status via ${source}: ${response.status}`,
|
||||
{ provider: 'gemini', accountId, httpStatus: response.status, source }
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return buildGeminiCliHttpFailureResult(
|
||||
accountId,
|
||||
authData.projectId,
|
||||
response.status,
|
||||
response.bodyText
|
||||
);
|
||||
}
|
||||
|
||||
const data = response.json as GeminiCliQuotaResponse | null;
|
||||
const rawBuckets = data?.buckets || [];
|
||||
const buckets = buildGeminiCliBuckets(rawBuckets);
|
||||
const supplementary = await supplementaryPromise;
|
||||
|
||||
if (verbose) {
|
||||
logger.info('gemini_cli.buckets_found', `Gemini CLI buckets found: ${buckets.length}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
bucketCount: buckets.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (supplementary.normalizedTier !== 'unknown') {
|
||||
setAccountTier('gemini', accountId, supplementary.normalizedTier);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
buckets,
|
||||
projectId: authData.projectId,
|
||||
tierLabel: supplementary.tierLabel,
|
||||
tierId: supplementary.tierId,
|
||||
creditBalance: supplementary.creditBalance,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: supplementary.normalizedTier,
|
||||
rawTierId: supplementary.tierId,
|
||||
rawTierLabel: supplementary.tierLabel,
|
||||
source: supplementary.tierId ? 'runtime_api' : 'runtime_inference',
|
||||
confidence: supplementary.tierId ? 'high' : 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
}),
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof GeminiManagedAuthUnavailableError) {
|
||||
return buildGeminiCliFailureResult(accountId, authData.projectId, {
|
||||
error: 'Gemini delegated auth refresh is temporarily unavailable',
|
||||
errorCode: 'managed_auth_unavailable',
|
||||
errorDetail: err.message,
|
||||
actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.',
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMsg =
|
||||
err instanceof Error && err.name === 'AbortError'
|
||||
? 'Request timeout'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Unknown error';
|
||||
|
||||
if (verbose) {
|
||||
logger.error('gemini_cli.quota_fetch_error', `Gemini CLI quota error: ${errorMsg}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
err: err instanceof Error ? { name: err.name, message: errorMsg } : { message: errorMsg },
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiCliFailureResult(accountId, authData.projectId, {
|
||||
error: errorMsg,
|
||||
errorCode:
|
||||
err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error',
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Gemini CLI account.
|
||||
*
|
||||
* Reads the on-disk auth file, emits the structured `gemini_cli.fetch_start`
|
||||
* and `gemini_cli.auth_file_missing` / `gemini_cli.token_expired` log events
|
||||
* (gated on `verbose`), and delegates to {@link fetchWithAuthData}. Token
|
||||
* values are never logged; only the expiry label is surfaced.
|
||||
*
|
||||
* @param accountId - Account identifier (email)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Quota result with buckets, percentages, tier, and entitlement evidence
|
||||
*/
|
||||
export async function fetchGeminiCliQuota(
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<GeminiCliQuotaResult> {
|
||||
if (verbose) {
|
||||
logger.info('gemini_cli.fetch_start', `Fetching Gemini CLI quota for ${accountId}...`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const authData = readGeminiCliAuthData(accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for Gemini account';
|
||||
if (verbose) {
|
||||
logger.error('gemini_cli.auth_file_missing', `Error: ${error}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return buildGeminiCliFailureResult(accountId, null, {
|
||||
error,
|
||||
errorCode: 'auth_file_missing',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (authData.isExpired && verbose) {
|
||||
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
||||
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
|
||||
logger.info(
|
||||
'gemini_cli.token_expired',
|
||||
`Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`,
|
||||
{ provider: 'gemini', accountId, tokenExpired: true, expiresAt: expiryLabel }
|
||||
);
|
||||
}
|
||||
|
||||
return await fetchWithAuthData(authData, accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for all configured Gemini CLI accounts in parallel.
|
||||
*
|
||||
* @param verbose - Show detailed diagnostics (forwarded to each per-account fetch)
|
||||
* @returns Array of `{ account, quota }` entries, one per active Gemini account.
|
||||
* Returns an empty array when there are no Gemini accounts configured.
|
||||
*/
|
||||
export async function fetchAllGeminiCliQuotas(
|
||||
verbose = false
|
||||
): Promise<{ account: string; quota: GeminiCliQuotaResult }[]> {
|
||||
const accounts = getProviderAccounts('gemini');
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
accounts.map(async (account) => ({
|
||||
account: account.id,
|
||||
quota: await fetchGeminiCliQuota(account.id, verbose),
|
||||
}))
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Shared utilities for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Includes the diagnostic logger (provider context = cliproxy:quota:gemini-cli)
|
||||
* and small value-normalization helpers used by multiple submodules. Token
|
||||
* values are never logged here; accountId is attached as provider context
|
||||
* only.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../../services/logging';
|
||||
|
||||
/**
|
||||
* Diagnostic-only logger for Gemini CLI quota fetch progress, upstream HTTP
|
||||
* status, and recovery hints. Token values live in auth files and are never
|
||||
* read into log messages.
|
||||
*/
|
||||
export const logger = createLogger('cliproxy:quota:gemini-cli');
|
||||
|
||||
/**
|
||||
* Normalize a raw value into a trimmed non-empty string, or null.
|
||||
* Returns null for empty strings, non-strings, or whitespace-only input.
|
||||
*/
|
||||
export function normalizeStringValue(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw value into a finite number, or null.
|
||||
* Accepts actual numbers and numeric strings; rejects NaN/Infinity.
|
||||
*/
|
||||
export function normalizeNumberValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON.parse that returns null on failure instead of throwing.
|
||||
* Used when normalizing upstream response bodies into a `json` field.
|
||||
*/
|
||||
export function safeParseJson(bodyText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the remaining milliseconds available before a deadline, clamped
|
||||
* to a minimum of 1ms so AbortController timeouts are always positive.
|
||||
*/
|
||||
export function getRemainingTimeoutMs(deadlineMs: number): number {
|
||||
return Math.max(1, deadlineMs - Date.now());
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Supplementary tier/credit metadata fetcher for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Wraps the `loadCodeAssist` endpoint to resolve the account's tier label,
|
||||
* tier id, normalized tier (free/pro/ultra/unknown), and Google One AI credit
|
||||
* balance. Runs alongside the primary quota fetch and shares the same
|
||||
* managed-auth context so auth-index lookups are deduped.
|
||||
*/
|
||||
|
||||
import {
|
||||
getProviderTierLabel,
|
||||
normalizeProviderTierId,
|
||||
} from '../../auth/provider-entitlement-evidence';
|
||||
import { performGeminiCliRequest } from './managed-request';
|
||||
import { logger, normalizeNumberValue, normalizeStringValue } from './shared-utils';
|
||||
import { GEMINI_CLI_CODE_ASSIST_URL, GEMINI_CLI_G1_CREDIT_TYPE } from './constants';
|
||||
import type {
|
||||
GeminiCliCodeAssistResponse,
|
||||
GeminiCliSupplementaryInfo,
|
||||
ManagedGeminiAuthContext,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Resolve the tier id from a loadCodeAssist response.
|
||||
* Prefers the paid tier id, then the current tier id. Lowercased.
|
||||
* Returns null if neither is present.
|
||||
*/
|
||||
export function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): string | null {
|
||||
if (!payload) return null;
|
||||
const currentTier = payload.currentTier ?? payload.current_tier;
|
||||
const paidTier = payload.paidTier ?? payload.paid_tier;
|
||||
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
|
||||
return rawId ? rawId.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human-readable tier label from the loadCodeAssist tier id.
|
||||
* Returns null when the tier id cannot be mapped to a known label.
|
||||
*/
|
||||
export function resolveGeminiCliTierLabel(
|
||||
payload: GeminiCliCodeAssistResponse | null
|
||||
): string | null {
|
||||
const tierId = resolveGeminiCliTierId(payload);
|
||||
return getProviderTierLabel(tierId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Google One AI credit balance for the account from the
|
||||
* loadCodeAssist response. Sums all credits with type `GOOGLE_ONE_AI` on the
|
||||
* paid tier (preferred) or current tier. Returns null if no matching credits
|
||||
* are present.
|
||||
*/
|
||||
export function resolveGeminiCliCreditBalance(
|
||||
payload: GeminiCliCodeAssistResponse | null
|
||||
): number | null {
|
||||
if (!payload) return null;
|
||||
|
||||
const paidTier = payload.paidTier ?? payload.paid_tier;
|
||||
const currentTier = payload.currentTier ?? payload.current_tier;
|
||||
const tier = paidTier ?? currentTier;
|
||||
if (!tier) return null;
|
||||
|
||||
const credits = tier.availableCredits ?? tier.available_credits ?? [];
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const credit of credits) {
|
||||
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
|
||||
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
|
||||
|
||||
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
|
||||
if (amount !== null) {
|
||||
total += amount;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
return found ? total : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch supplementary tier/credit metadata for a Gemini account via the
|
||||
* loadCodeAssist endpoint. Never throws: on any failure returns a
|
||||
* supplementary info with `normalizedTier: 'unknown'` so the primary quota
|
||||
* fetch can still complete. Diagnostic logging is gated on `verbose` and
|
||||
* records only accountId, HTTP status, and the source (managed/direct);
|
||||
* token values are never logged.
|
||||
*/
|
||||
export async function fetchGeminiCliSupplementary(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
verbose: boolean,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<GeminiCliSupplementaryInfo> {
|
||||
const requestBody = JSON.stringify({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await performGeminiCliRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
GEMINI_CLI_CODE_ASSIST_URL,
|
||||
requestBody,
|
||||
false,
|
||||
authContext
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
if (verbose) {
|
||||
const source = response.viaManagement ? 'managed' : 'direct';
|
||||
logger.info(
|
||||
'gemini_cli.supplementary_metadata_unavailable',
|
||||
`Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`,
|
||||
{ provider: 'gemini', accountId, httpStatus: response.status, source }
|
||||
);
|
||||
}
|
||||
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||
}
|
||||
|
||||
const payload = response.json as GeminiCliCodeAssistResponse | null;
|
||||
return {
|
||||
tierLabel: resolveGeminiCliTierLabel(payload),
|
||||
tierId: resolveGeminiCliTierId(payload),
|
||||
creditBalance: resolveGeminiCliCreditBalance(payload),
|
||||
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.info(
|
||||
'gemini_cli.supplementary_metadata_skipped',
|
||||
`Gemini CLI supplementary metadata skipped: ${message}`,
|
||||
{
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
err: error instanceof Error ? { name: error.name, message } : { message },
|
||||
}
|
||||
);
|
||||
}
|
||||
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Token parsing helpers for Gemini CLI auth files.
|
||||
*
|
||||
* Extracts access tokens, expiry, and project IDs from the raw auth file
|
||||
* payload. Gemini auth files come in two structural variants:
|
||||
* - flat: { access_token, expired, project_id, account }
|
||||
* - nested:{ token: { access_token, expiry }, project_id, account }
|
||||
* These helpers handle both without throwing on shape mismatches.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the access token from a Gemini auth file payload.
|
||||
* Handles both flat (`access_token`) and nested (`token.access_token`) shapes.
|
||||
* Returns null if no usable token is present.
|
||||
*/
|
||||
export function extractAccessToken(data: Record<string, unknown>): string | null {
|
||||
// Flat structure: { access_token: "..." }
|
||||
if (typeof data.access_token === 'string') {
|
||||
return data.access_token;
|
||||
}
|
||||
// Nested structure: { token: { access_token: "..." } }
|
||||
if (data.token && typeof data.token === 'object') {
|
||||
const token = data.token as Record<string, unknown>;
|
||||
if (typeof token.access_token === 'string') {
|
||||
return token.access_token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the token expiry from a Gemini auth file payload.
|
||||
* Handles both flat (`expired`) and nested (`token.expiry`) shapes.
|
||||
* Returns the raw string/number, or null if absent.
|
||||
*/
|
||||
export function extractExpiry(data: Record<string, unknown>): string | number | null {
|
||||
// Flat structure: { expired: "..." }
|
||||
if (typeof data.expired === 'string') {
|
||||
return data.expired;
|
||||
}
|
||||
if (typeof data.expired === 'number') {
|
||||
return data.expired;
|
||||
}
|
||||
// Nested structure: { token: { expiry: "..." } }
|
||||
if (data.token && typeof data.token === 'object') {
|
||||
const token = data.token as Record<string, unknown>;
|
||||
if (typeof token.expiry === 'string') {
|
||||
return token.expiry;
|
||||
}
|
||||
if (typeof token.expiry === 'number') {
|
||||
return token.expiry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the project ID from an auth file's `account` field.
|
||||
* Input shape: "user@example.com (cloudaicompanion-abc-123)"
|
||||
* Returns the last parenthesized segment, or null if no match.
|
||||
*
|
||||
* Example:
|
||||
* "user@example.com (cloudaicompanion-abc-123)" -> "cloudaicompanion-abc-123"
|
||||
*/
|
||||
export function resolveGeminiCliProjectId(accountField: string): string | null {
|
||||
const regex = /\(([^()]+)\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let lastMatch: string | null = null;
|
||||
while ((match = regex.exec(accountField)) !== null) {
|
||||
lastMatch = match[1];
|
||||
}
|
||||
return lastMatch;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shared types for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Extracted from the original quota-fetcher-gemini-cli.ts god file. These
|
||||
* interfaces describe raw API response shapes, internal parsed structures,
|
||||
* and managed-auth context used across the submodules.
|
||||
*/
|
||||
|
||||
import type { GeminiCliBucket, GeminiCliQuotaResult } from '../quota-types';
|
||||
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
|
||||
|
||||
/** Auth data extracted from a Gemini CLI auth file. */
|
||||
export interface GeminiCliAuthData {
|
||||
accessToken: string;
|
||||
projectId: string | null;
|
||||
isExpired: boolean;
|
||||
expiresAt: string | number | null;
|
||||
}
|
||||
|
||||
/** Raw bucket shape returned by the Gemini CLI quota API. */
|
||||
export interface RawGeminiCliBucket {
|
||||
model_id?: string;
|
||||
modelId?: string;
|
||||
token_type?: string | null;
|
||||
tokenType?: string | null;
|
||||
remaining_fraction?: number;
|
||||
remainingFraction?: number;
|
||||
remaining_amount?: number;
|
||||
remainingAmount?: number;
|
||||
reset_time?: string | null;
|
||||
resetTime?: string | null;
|
||||
}
|
||||
|
||||
/** Raw quota API response wrapper. */
|
||||
export interface GeminiCliQuotaResponse {
|
||||
buckets?: RawGeminiCliBucket[];
|
||||
}
|
||||
|
||||
/** Credit entry inside a tier (supports snake_case and camelCase variants). */
|
||||
export interface GeminiCliCredits {
|
||||
creditType?: string;
|
||||
credit_type?: string;
|
||||
creditAmount?: string | number;
|
||||
credit_amount?: string | number;
|
||||
}
|
||||
|
||||
/** User tier inside a loadCodeAssist response. */
|
||||
export interface GeminiCliUserTier {
|
||||
id?: string;
|
||||
availableCredits?: GeminiCliCredits[];
|
||||
available_credits?: GeminiCliCredits[];
|
||||
}
|
||||
|
||||
/** loadCodeAssist response shape (currentTier + paidTier). */
|
||||
export interface GeminiCliCodeAssistResponse {
|
||||
currentTier?: GeminiCliUserTier | null;
|
||||
current_tier?: GeminiCliUserTier | null;
|
||||
paidTier?: GeminiCliUserTier | null;
|
||||
paid_tier?: GeminiCliUserTier | null;
|
||||
}
|
||||
|
||||
/** Parsed error body extracted from an upstream non-200 response. */
|
||||
export interface ParsedGeminiCliErrorBody {
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Supplementary tier/credit info resolved alongside the quota buckets. */
|
||||
export interface GeminiCliSupplementaryInfo {
|
||||
tierLabel: string | null;
|
||||
tierId: string | null;
|
||||
creditBalance: number | null;
|
||||
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
}
|
||||
|
||||
/** Auth-file entry as returned by the CLIProxy management API. */
|
||||
export interface ManagementAuthFile {
|
||||
auth_index?: string | number;
|
||||
provider?: string;
|
||||
type?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** api-call response envelope from the CLIProxy management endpoint. */
|
||||
export interface ManagementApiCallResponse {
|
||||
status_code?: number;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** Normalized HTTP response used by both direct and managed code paths. */
|
||||
export interface ManagedResponse {
|
||||
status: number;
|
||||
bodyText: string;
|
||||
json: unknown;
|
||||
viaManagement: boolean;
|
||||
}
|
||||
|
||||
/** Per-account managed-auth context used to dedupe auth-index lookups. */
|
||||
export interface ManagedGeminiAuthContext {
|
||||
authIndexLookupPromise?: Promise<ManagedGeminiAuthLookupResult>;
|
||||
}
|
||||
|
||||
/** Result of looking up a Gemini auth file index via management API. */
|
||||
export interface ManagedGeminiAuthLookupResult {
|
||||
authIndex: string | number | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
/** Result of performing a managed Gemini upstream request. */
|
||||
export interface ManagedGeminiRequestResult {
|
||||
response: ManagedResponse | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
/** Options bag for {@link buildGeminiCliFailureResult}. */
|
||||
export interface GeminiCliFailureResultOptions {
|
||||
error: string;
|
||||
httpStatus?: number;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
actionHint?: string;
|
||||
retryable?: boolean;
|
||||
needsReauth?: boolean;
|
||||
isForbidden?: boolean;
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
// Re-export the public result shapes so callers can import everything from
|
||||
// the barrel without reaching into quota-types directly.
|
||||
export type { GeminiCliBucket, GeminiCliQuotaResult, ProviderEntitlementEvidence };
|
||||
+17
-1094
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* fetchAccountQuota: top-level Antigravity account quota orchestrator.
|
||||
*
|
||||
* Reads the local auth file, calls loadCodeAssist (project + tier), then
|
||||
* fetchAvailableModels. Merges the results into a QuotaResult, attaching
|
||||
* entitlement evidence and persisting the resolved tier back to the account
|
||||
* manager. Preserves the structured createLogger('cliproxy:quota:fetcher')
|
||||
* logging from P3 (quota.fetch.start / auth_state / project_resolved / models).
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import type { AccountTier } from '../../accounts/account-manager';
|
||||
import { setAccountTier } from '../../accounts/account-manager';
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import { createLogger } from '../../../services/logging';
|
||||
|
||||
import { readAuthData } from './auth-file-reader';
|
||||
import { fetchAvailableModels } from './available-models-fetcher';
|
||||
import { getProjectId } from './project-lookup';
|
||||
import { mergeAntigravityTierEvidence } from './status-classifier';
|
||||
import type { QuotaResult } from './types';
|
||||
|
||||
const logger = createLogger('cliproxy:quota:fetcher');
|
||||
|
||||
/**
|
||||
* Fetch quota for an Antigravity account.
|
||||
*
|
||||
* @param provider - Provider name (only 'agy' supported)
|
||||
* @param accountId - Account identifier (email)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Quota result with models and percentages
|
||||
*/
|
||||
export async function fetchAccountQuota(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<QuotaResult> {
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.start', 'Fetching quota for account', { provider, accountId });
|
||||
|
||||
// Only Antigravity supports quota fetching
|
||||
if (provider !== 'agy') {
|
||||
const error = `Quota not supported for provider: ${provider}`;
|
||||
if (verbose) logger.warn('quota.fetch.unsupported_provider', error, { provider });
|
||||
// Stable machine code so callers branch on a code, not the human string.
|
||||
// This is "no quota API for this provider", which is healthy — distinct
|
||||
// from a transient fetch failure or an expired token.
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: 'quota_not_supported',
|
||||
};
|
||||
}
|
||||
|
||||
// Read auth data from auth file (checks both active and paused directories)
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for account';
|
||||
if (verbose) logger.warn('quota.fetch.auth_missing', error, { provider, accountId });
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: 'auth_file_missing',
|
||||
actionHint: 'Reconnect this account so CCS can read a current auth token.',
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = authData.accessToken;
|
||||
if (verbose) {
|
||||
const expiryState = authData.isExpired
|
||||
? 'expired'
|
||||
: authData.expiresAt
|
||||
? `expires ${authData.expiresAt}`
|
||||
: 'expiry unknown';
|
||||
logger.info('quota.fetch.auth_state', `Auth token state: ${expiryState}`, {
|
||||
provider,
|
||||
state: expiryState,
|
||||
});
|
||||
}
|
||||
|
||||
// Get project ID and tier - prefer stored project ID, but always call API for tier
|
||||
let projectId = authData.projectId;
|
||||
let apiTier: AccountTier = 'unknown';
|
||||
let rawTierId: string | null = null;
|
||||
let rawTierLabel: string | null = null;
|
||||
|
||||
// Always call loadCodeAssist to get accurate tier from API.
|
||||
// If the file token is stale, the helper retries through CLIProxy management auth.
|
||||
const lastProjectResult = await getProjectId(accountId, accessToken);
|
||||
|
||||
if (!lastProjectResult.projectId && !projectId) {
|
||||
const error = lastProjectResult.error || 'Failed to retrieve project ID';
|
||||
if (verbose)
|
||||
logger.warn('quota.fetch.project_lookup_failed', error, {
|
||||
provider,
|
||||
errorCode: lastProjectResult.errorCode,
|
||||
httpStatus: lastProjectResult.httpStatus,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: lastProjectResult.errorCode,
|
||||
errorDetail: lastProjectResult.errorDetail,
|
||||
actionHint: lastProjectResult.actionHint,
|
||||
retryable: lastProjectResult.retryable,
|
||||
httpStatus: lastProjectResult.httpStatus,
|
||||
needsReauth: lastProjectResult.needsReauth,
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
entitlement: lastProjectResult.entitlement,
|
||||
isExpired: authData.isExpired,
|
||||
expiresAt: authData.expiresAt || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Use API project ID if available, else fallback to stored
|
||||
projectId = lastProjectResult.projectId || projectId;
|
||||
apiTier = lastProjectResult.tier || 'unknown';
|
||||
rawTierId = lastProjectResult.rawTierId || null;
|
||||
rawTierLabel = lastProjectResult.rawTierLabel || null;
|
||||
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.project_resolved', `Project ID: ${projectId || 'not found'}`, {
|
||||
provider,
|
||||
});
|
||||
|
||||
// Fetch models with quota
|
||||
const result = await fetchAvailableModels(accountId, accessToken, projectId as string);
|
||||
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.models', `Models found: ${result.models.length}`, {
|
||||
provider,
|
||||
count: result.models.length,
|
||||
});
|
||||
result.accountId = accountId;
|
||||
result.projectId = projectId || undefined;
|
||||
|
||||
// Determine tier from API response only
|
||||
if (result.success) {
|
||||
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
|
||||
result.tier = finalTier;
|
||||
result.entitlement = buildProviderEntitlementEvidence({
|
||||
normalizedTier: finalTier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : 'runtime_inference',
|
||||
confidence: rawTierId ? 'high' : 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
});
|
||||
if (finalTier !== 'unknown') {
|
||||
setAccountTier(provider, accountId, finalTier);
|
||||
}
|
||||
} else {
|
||||
result.isExpired = authData.isExpired;
|
||||
result.expiresAt = authData.expiresAt || undefined;
|
||||
result.entitlement = mergeAntigravityTierEvidence(
|
||||
result.entitlement,
|
||||
apiTier,
|
||||
rawTierId,
|
||||
rawTierLabel
|
||||
);
|
||||
}
|
||||
|
||||
if (verbose && result.error) {
|
||||
console.log(`[!] Error: ${result.error}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* fetchAllProviderQuotas and findAvailableAccount.
|
||||
*
|
||||
* fetchAllProviderQuotas fans quota fetches out across all accounts of a
|
||||
* provider in parallel and groups them by GCP project id (accounts that share
|
||||
* a project pool quota together, so failover between them won't help).
|
||||
* findAvailableAccount wraps that to pick the first account that still has
|
||||
* remaining quota (used by the auto-switch preflight check).
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import { getProviderAccounts, type AccountInfo } from '../../accounts/account-manager';
|
||||
|
||||
import { fetchAccountQuota } from './account-quota-fetcher';
|
||||
import { readProjectIdFromAuthFile } from './auth-file-reader';
|
||||
import type { AllAccountsQuotaResult, QuotaResult } from './types';
|
||||
|
||||
/**
|
||||
* Fetch quota for all accounts of a provider.
|
||||
* Also detects accounts sharing the same GCP project (failover won't help).
|
||||
*
|
||||
* @param provider - Provider name (only 'agy' supported for quota)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Results for all accounts with project grouping
|
||||
*/
|
||||
export async function fetchAllProviderQuotas(
|
||||
provider: CLIProxyProvider,
|
||||
verbose = false
|
||||
): Promise<AllAccountsQuotaResult> {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const results: AllAccountsQuotaResult = {
|
||||
provider,
|
||||
accounts: [],
|
||||
projectGroups: {},
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Fetch quota for each account in parallel
|
||||
const quotaPromises = accounts.map(async (account) => {
|
||||
const quota = await fetchAccountQuota(provider, account.id, verbose);
|
||||
|
||||
// Read project ID from auth file if not in quota result
|
||||
let projectId = quota.projectId;
|
||||
if (!projectId) {
|
||||
projectId = readProjectIdFromAuthFile(provider, account.id) || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
quota: { ...quota, accountId: account.id, projectId },
|
||||
};
|
||||
});
|
||||
|
||||
const quotaResults = await Promise.all(quotaPromises);
|
||||
|
||||
// Build project groups for detecting shared projects
|
||||
for (const { account, quota } of quotaResults) {
|
||||
results.accounts.push({ account, quota });
|
||||
|
||||
if (quota.projectId) {
|
||||
if (!results.projectGroups[quota.projectId]) {
|
||||
results.projectGroups[quota.projectId] = [];
|
||||
}
|
||||
results.projectGroups[quota.projectId].push(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available account with remaining quota.
|
||||
* Used by preflight check for auto-switching.
|
||||
*
|
||||
* @param provider - Provider name
|
||||
* @param excludeAccountId - Account to exclude (current exhausted account)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Account with available quota, or null if none available
|
||||
*/
|
||||
export async function findAvailableAccount(
|
||||
provider: CLIProxyProvider,
|
||||
excludeAccountId?: string,
|
||||
verbose = false
|
||||
): Promise<{ account: AccountInfo; quota: QuotaResult } | null> {
|
||||
const allQuotas = await fetchAllProviderQuotas(provider, verbose);
|
||||
|
||||
// Get excluded account's project ID to avoid switching to same-project accounts
|
||||
const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota
|
||||
.projectId;
|
||||
|
||||
for (const { account, quota } of allQuotas.accounts) {
|
||||
// Skip excluded account
|
||||
if (excludeAccountId && account.id === excludeAccountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip failed quota fetches
|
||||
if (!quota.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip accounts sharing the same GCP project (quota is pooled)
|
||||
if (excludedProjectId && quota.projectId === excludedProjectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any model has remaining quota (> 5% to avoid edge cases)
|
||||
const hasQuota = quota.models.some((m) => m.percentage > 5);
|
||||
if (hasQuota) {
|
||||
return { account, quota };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Auth file reader for Antigravity quota fetching.
|
||||
*
|
||||
* Reads the local Antigravity auth file (active or paused directory) and
|
||||
* extracts the access token, refresh token, project id, and expiry state.
|
||||
* Falls back to scanning the directory and matching by the embedded email
|
||||
* field when the canonical sanitized filename is not present.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { getAuthDir } from '../../config/config-generator';
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import { isTokenExpired, sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { getPausedDir } from '../../accounts/account-manager';
|
||||
import type { AntigravityAuthFile, AuthData } from './types';
|
||||
|
||||
/**
|
||||
* Read auth data from the auth file (access token, project_id, expiry state).
|
||||
* Checks both active and paused auth directories (quota is needed for paused
|
||||
* accounts too).
|
||||
*/
|
||||
export function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null {
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
|
||||
// Sanitize accountId (email) to match auth file naming: @ and . → _
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
|
||||
const expectedFile = `${prefix}${sanitizedId}.json`;
|
||||
|
||||
for (const authDir of authDirs) {
|
||||
if (!fs.existsSync(authDir)) continue;
|
||||
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
|
||||
// Direct file access (most common case)
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
if (!data.access_token) continue;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan directory for matching email in file content
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
||||
const candidatePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
// Match by email field inside the auth file
|
||||
if (data.email === accountId && data.access_token) {
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read project ID directly from auth file without making an API call.
|
||||
* Used for quick project ID comparison in the doctor command.
|
||||
*/
|
||||
export function readProjectIdFromAuthFile(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string
|
||||
): string | null {
|
||||
const authData = readAuthData(provider, accountId);
|
||||
return authData?.projectId || null;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* fetchAvailableModels call for Antigravity quota.
|
||||
*
|
||||
* Fetches the model -> remaining-fraction map from the Cloud Code internal
|
||||
* API and projects it into ModelQuota[] percentages (0-100). The projectId
|
||||
* is intentionally NOT sent in the body (CLIProxyAPI sends an empty {} body
|
||||
* for this endpoint); it is accepted only for symmetry with the project
|
||||
* lookup flow.
|
||||
*/
|
||||
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import { ANTIGRAVITY_API_BASE, ANTIGRAVITY_API_VERSION, FETCHMODELS_HEADERS } from './constants';
|
||||
import { performAntigravityRequest } from './http-client';
|
||||
import { buildAntigravityFailure } from './status-classifier';
|
||||
import type { FetchAvailableModelsResponse, ModelQuota, QuotaResult } from './types';
|
||||
|
||||
/**
|
||||
* Fetch available models with quota info.
|
||||
* Note: projectId is kept for potential future use but not sent in body
|
||||
* (CLIProxyAPI sends empty {} body for this endpoint).
|
||||
*/
|
||||
export async function fetchAvailableModels(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
_projectId: string
|
||||
): Promise<QuotaResult> {
|
||||
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`;
|
||||
const response = await performAntigravityRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
url,
|
||||
FETCHMODELS_HEADERS,
|
||||
JSON.stringify({})
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
...buildAntigravityFailure(response.status, response.bodyText),
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json as FetchAvailableModelsResponse | null;
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid quota payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const models: ModelQuota[] = [];
|
||||
|
||||
if (data.models && typeof data.models === 'object') {
|
||||
for (const [modelId, modelData] of Object.entries(data.models)) {
|
||||
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
|
||||
if (!quotaInfo) continue;
|
||||
|
||||
const remaining =
|
||||
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
||||
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
|
||||
|
||||
let percentage: number;
|
||||
if (typeof remaining === 'number' && isFinite(remaining)) {
|
||||
percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
|
||||
} else if (resetTime) {
|
||||
percentage = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push({
|
||||
name: modelId,
|
||||
displayName: modelData.displayName,
|
||||
percentage,
|
||||
resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
models,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Constants for the Antigravity quota fetcher.
|
||||
*
|
||||
* Google Cloud Code internal API endpoints, fixed headers used by the
|
||||
* CLIProxyAPIPlus control-plane requests, and the shared timeout applied to
|
||||
* every Antigravity management API call.
|
||||
*/
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
export const ANTIGRAVITY_DAILY_API_BASE = 'https://daily-cloudcode-pa.googleapis.com';
|
||||
export const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||
export const ANTIGRAVITY_API_VERSION = 'v1internal';
|
||||
export const ANTIGRAVITY_LOADCODEASSIST_BASE_URLS = [
|
||||
ANTIGRAVITY_DAILY_API_BASE,
|
||||
ANTIGRAVITY_API_BASE,
|
||||
] as const;
|
||||
export const MANAGEMENT_API_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Headers for loadCodeAssist (matches current CLIProxyAPIPlus control-plane requests) */
|
||||
export const LOADCODEASSIST_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.21.9 darwin/arm64 google-api-nodejs-client/10.3.0',
|
||||
'X-Goog-Api-Client': 'gl-node/22.21.1',
|
||||
};
|
||||
|
||||
/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */
|
||||
export const FETCHMODELS_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.104.0 darwin/arm64',
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* HTTP transport for Antigravity quota requests.
|
||||
*
|
||||
* Wraps fetch() with three fallback strategies:
|
||||
* 1. Direct call with the local access token.
|
||||
* 2. If that returns 401 (token rejected), retry through CLIProxy management
|
||||
* auth using the proxy's stored token.
|
||||
* 3. For loadCodeAssist, fall back across the daily then prod Cloud Code hosts.
|
||||
*
|
||||
* Every call is bounded by MANAGEMENT_API_TIMEOUT_MS via an AbortController.
|
||||
* Network errors become synthetic 503 responses; abort timeouts become 408.
|
||||
*/
|
||||
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import {
|
||||
buildManagementHeaders,
|
||||
buildProxyUrl,
|
||||
getProxyTarget,
|
||||
} from '../../proxy/proxy-target-resolver';
|
||||
import { MANAGEMENT_API_TIMEOUT_MS } from './constants';
|
||||
import type { ManagedResponse, ManagementApiCallResponse, ManagementAuthFile } from './types';
|
||||
|
||||
/** Best-effort JSON.parse; returns null on failure. */
|
||||
export function safeParseJson(bodyText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the response body once and return a normalized ManagedResponse. */
|
||||
async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
): Promise<ManagedResponse> {
|
||||
const bodyText = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement,
|
||||
};
|
||||
}
|
||||
|
||||
/** Does this management-API auth file belong to the given Antigravity account? */
|
||||
function isAntigravityAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
|
||||
const provider = (file.provider || file.type || '').trim().toLowerCase();
|
||||
if (provider !== 'antigravity' && provider !== 'agy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedAccount = accountId.trim().toLowerCase();
|
||||
const normalizedEmail = file.email?.trim().toLowerCase();
|
||||
if (normalizedEmail && normalizedEmail === normalizedAccount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = file.name?.trim().toLowerCase();
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
|
||||
return (
|
||||
normalizedName === `antigravity-${sanitizedAccount}.json` ||
|
||||
normalizedName === `agy-${sanitizedAccount}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/** Ask CLIProxy management API for the auth_index of the Antigravity account. */
|
||||
async function findManagedAntigravityAuthIndex(accountId: string): Promise<string | number | null> {
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: ManagementAuthFile[] };
|
||||
const match = data.files?.find((file) => isAntigravityAuthFileForAccount(file, accountId));
|
||||
return match?.auth_index ?? null;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a request through the CLIProxy management api-call endpoint using the
|
||||
* proxy's stored token (substituted server-side as $TOKEN$). Returns null if
|
||||
* the proxy can't handle the request.
|
||||
*/
|
||||
async function performManagedAntigravityRequest(
|
||||
accountId: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<ManagedResponse | null> {
|
||||
const authIndex = await findManagedAntigravityAuthIndex(accountId);
|
||||
if (authIndex === null || authIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
auth_index: authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: {
|
||||
...headers,
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
},
|
||||
data: body,
|
||||
}),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiResponse = (await response.json()) as ManagementApiCallResponse;
|
||||
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
|
||||
return {
|
||||
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement: true,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single Antigravity POST. Tries direct with the local access token
|
||||
* first; on 401, retries through CLIProxy management auth. Network errors map
|
||||
* to synthetic 503 responses so the caller's status-based classifier still
|
||||
* works; abort timeouts map to 408.
|
||||
*/
|
||||
export async function performAntigravityRequest(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<ManagedResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const directResult = await readManagedResponse(response, false);
|
||||
if (directResult.status !== 401) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const managedResult = await performManagedAntigravityRequest(accountId, url, headers, body);
|
||||
return managedResult ?? directResult;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return {
|
||||
status: 408,
|
||||
bodyText: '',
|
||||
json: null,
|
||||
viaManagement: false,
|
||||
};
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return {
|
||||
status: 503,
|
||||
bodyText: message,
|
||||
json: null,
|
||||
viaManagement: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a request against each base URL in order, returning the first 2xx
|
||||
* response. If none succeed, return the last failure (or a synthetic 503 if
|
||||
* no URLs were attempted).
|
||||
*/
|
||||
export async function performAntigravityRequestWithBaseUrlFallback(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
baseUrls: readonly string[],
|
||||
apiPath: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<ManagedResponse> {
|
||||
let lastResponse: ManagedResponse | null = null;
|
||||
|
||||
for (const baseUrl of baseUrls) {
|
||||
const response = await performAntigravityRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
`${baseUrl}/${apiPath}`,
|
||||
headers,
|
||||
body
|
||||
);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
}
|
||||
lastResponse = response;
|
||||
}
|
||||
|
||||
return (
|
||||
lastResponse ?? {
|
||||
status: 503,
|
||||
bodyText: 'No Antigravity API endpoint available',
|
||||
json: null,
|
||||
viaManagement: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Antigravity loadCodeAssist project + tier lookup.
|
||||
*
|
||||
* Calls loadCodeAssist against the daily then prod Cloud Code hosts to resolve
|
||||
* the GCP project id and the account tier (paidTier.id takes priority over
|
||||
* currentTier.id). Returns a structured ProjectLookupResult that the top-level
|
||||
* fetchAccountQuota merges into a QuotaResult.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildProviderEntitlementEvidence,
|
||||
getProviderTierLabel,
|
||||
normalizeProviderTierId,
|
||||
} from '../../auth/provider-entitlement-evidence';
|
||||
import {
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
|
||||
LOADCODEASSIST_HEADERS,
|
||||
} from './constants';
|
||||
import { performAntigravityRequestWithBaseUrlFallback } from './http-client';
|
||||
import { buildAntigravityFailure } from './status-classifier';
|
||||
import type { LoadCodeAssistResponse, ProjectLookupResult } from './types';
|
||||
|
||||
/**
|
||||
* Get project ID and tier via loadCodeAssist endpoint.
|
||||
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier).
|
||||
* Falls back across the daily then prod Cloud Code hosts.
|
||||
*/
|
||||
export async function getProjectId(
|
||||
accountId: string,
|
||||
accessToken: string
|
||||
): Promise<ProjectLookupResult> {
|
||||
const body = JSON.stringify({
|
||||
metadata: {
|
||||
ide_name: 'antigravity',
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: '1.21.9',
|
||||
},
|
||||
});
|
||||
const response = await performAntigravityRequestWithBaseUrlFallback(
|
||||
accountId,
|
||||
accessToken,
|
||||
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
|
||||
`${ANTIGRAVITY_API_VERSION}:loadCodeAssist`,
|
||||
LOADCODEASSIST_HEADERS,
|
||||
body
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return {
|
||||
projectId: null,
|
||||
...buildAntigravityFailure(response.status, response.bodyText),
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json as LoadCodeAssistResponse | null;
|
||||
if (!data) {
|
||||
return {
|
||||
projectId: null,
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid project payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract project ID from response
|
||||
let projectId: string | undefined;
|
||||
if (typeof data.cloudaicompanionProject === 'string') {
|
||||
projectId = data.cloudaicompanionProject;
|
||||
} else if (typeof data.cloudaicompanionProject === 'object') {
|
||||
projectId = data.cloudaicompanionProject?.id;
|
||||
}
|
||||
|
||||
if (!projectId?.trim()) {
|
||||
return {
|
||||
projectId: null,
|
||||
error: 'Sign in to Antigravity app to activate quota.',
|
||||
errorCode: 'account_unprovisioned',
|
||||
actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.',
|
||||
isUnprovisioned: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
notes: 'Project provisioning is incomplete for this account.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract tier - paidTier reflects actual subscription status, takes priority
|
||||
const rawTierId = (data.paidTier?.id || data.currentTier?.id || '').trim() || null;
|
||||
const tier = normalizeProviderTierId(rawTierId);
|
||||
|
||||
return {
|
||||
projectId: projectId.trim(),
|
||||
tier,
|
||||
rawTierId,
|
||||
rawTierLabel: getProviderTierLabel(rawTierId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Status classifier for Antigravity quota fetch failures.
|
||||
*
|
||||
* Maps an upstream HTTP status code (plus optional response body) into a stable
|
||||
* QuotaResult failure fragment: error code, action hint, retryability, and
|
||||
* provider entitlement evidence. Also merges tier evidence from a successful
|
||||
* project lookup with entitlement evidence derived from a failed models fetch.
|
||||
*/
|
||||
|
||||
import type { AccountTier } from '../../accounts/account-manager';
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
|
||||
import type { QuotaResult } from './types';
|
||||
|
||||
/** Trim and cap upstream error bodies to keep payloads small. */
|
||||
export function normalizeErrorDetail(bodyText: string): string | undefined {
|
||||
const normalized = bodyText.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= 400) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, 397)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the failure fragment for an Antigravity quota request. The returned
|
||||
* object is spread into a QuotaResult by callers. Status 401/403/429/408/5xx
|
||||
* each have their own stable error code and capacity/access state signal.
|
||||
*/
|
||||
export function buildAntigravityFailure(
|
||||
status: number | undefined,
|
||||
bodyText?: string
|
||||
): Pick<
|
||||
QuotaResult,
|
||||
| 'error'
|
||||
| 'errorCode'
|
||||
| 'errorDetail'
|
||||
| 'actionHint'
|
||||
| 'retryable'
|
||||
| 'httpStatus'
|
||||
| 'needsReauth'
|
||||
| 'entitlement'
|
||||
> & { isForbidden?: boolean } {
|
||||
const detail = normalizeErrorDetail(bodyText || '');
|
||||
|
||||
if (status === 401) {
|
||||
return {
|
||||
httpStatus: 401,
|
||||
error: 'Token expired or invalid',
|
||||
errorCode: 'reauth_required',
|
||||
actionHint:
|
||||
'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.',
|
||||
needsReauth: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return {
|
||||
httpStatus: 403,
|
||||
error: 'Access forbidden',
|
||||
errorCode: 'quota_api_forbidden',
|
||||
actionHint: 'This account does not have Gemini Code Assist quota access.',
|
||||
isForbidden: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return {
|
||||
httpStatus: 429,
|
||||
error: 'Rate limited - try again later',
|
||||
errorCode: 'rate_limited',
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 408) {
|
||||
return {
|
||||
httpStatus: 408,
|
||||
error: 'Request timeout',
|
||||
errorCode: 'network_timeout',
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof status === 'number' && status >= 500) {
|
||||
return {
|
||||
httpStatus: status,
|
||||
error: `API error: ${status}`,
|
||||
errorCode: 'provider_unavailable',
|
||||
actionHint: 'Retry later. The provider appears unavailable.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof status === 'number' && status >= 400) {
|
||||
return {
|
||||
httpStatus: status,
|
||||
error: `API error: ${status}`,
|
||||
errorCode: 'quota_request_failed',
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Quota request failed',
|
||||
errorCode: 'quota_request_failed',
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge entitlement evidence from a successful project lookup with evidence
|
||||
* from a failed models fetch. A known tier id from the project lookup always
|
||||
* wins (runtime_api source, high confidence); otherwise we fall back to the
|
||||
* pre-existing evidence or build a fresh runtime_inference record.
|
||||
*/
|
||||
export function mergeAntigravityTierEvidence(
|
||||
entitlement: ProviderEntitlementEvidence | undefined,
|
||||
tier: AccountTier,
|
||||
rawTierId: string | null,
|
||||
rawTierLabel: string | null
|
||||
): ProviderEntitlementEvidence | undefined {
|
||||
if (tier === 'unknown' && !entitlement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildProviderEntitlementEvidence({
|
||||
normalizedTier: tier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'),
|
||||
confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'),
|
||||
accessState: entitlement?.accessState ?? 'unknown',
|
||||
capacityState: entitlement?.capacityState ?? 'unknown',
|
||||
notes: entitlement?.notes ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Shared types for the Antigravity quota fetcher.
|
||||
*
|
||||
* Public types (ModelQuota, QuotaResult, AllAccountsQuotaResult) are re-exported
|
||||
* from the barrel at ../quota-fetcher.ts so existing import paths keep working.
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import type { AccountInfo, AccountTier } from '../../accounts/account-manager';
|
||||
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
|
||||
|
||||
/** Individual model quota info */
|
||||
export interface ModelQuota {
|
||||
/** Model name, e.g., "gemini-3-pro-high" */
|
||||
name: string;
|
||||
/** Display name from API, e.g., "Gemini 3 Pro" */
|
||||
displayName?: string;
|
||||
/** Remaining quota as percentage (0-100) */
|
||||
percentage: number;
|
||||
/** ISO timestamp when quota resets, null if unknown */
|
||||
resetTime: string | null;
|
||||
}
|
||||
|
||||
/** Quota fetch result */
|
||||
export interface QuotaResult {
|
||||
/** Whether fetch succeeded */
|
||||
success: boolean;
|
||||
/** Quota for each available model */
|
||||
models: ModelQuota[];
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Upstream HTTP status when available */
|
||||
httpStatus?: number;
|
||||
/** Stable machine-readable error code */
|
||||
errorCode?: string;
|
||||
/** Additional provider-specific detail/code from upstream */
|
||||
errorDetail?: string;
|
||||
/** True if account lacks quota access (403) */
|
||||
isForbidden?: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error?: string;
|
||||
/** Provider-specific remediation guidance */
|
||||
actionHint?: string;
|
||||
/** True when the failure is temporary and retrying later may help */
|
||||
retryable?: boolean;
|
||||
/** True if token is expired and needs re-auth */
|
||||
isExpired?: boolean;
|
||||
/** True if token refresh cannot proceed and the account should be re-authenticated */
|
||||
needsReauth?: boolean;
|
||||
/** ISO timestamp when token expires/expired */
|
||||
expiresAt?: string;
|
||||
/** True if account hasn't been activated in official Antigravity app */
|
||||
isUnprovisioned?: boolean;
|
||||
/** Account ID (email) this quota belongs to */
|
||||
accountId?: string;
|
||||
/** GCP project ID for this account */
|
||||
projectId?: string;
|
||||
/** Detected account tier based on model access */
|
||||
tier?: AccountTier;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
/** Result for all accounts of a provider */
|
||||
export interface AllAccountsQuotaResult {
|
||||
/** Provider name */
|
||||
provider: CLIProxyProvider;
|
||||
/** Results per account */
|
||||
accounts: Array<{
|
||||
account: AccountInfo;
|
||||
quota: QuotaResult;
|
||||
}>;
|
||||
/** Accounts grouped by project ID (for detecting shared projects) */
|
||||
projectGroups: Record<string, string[]>;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types (not part of the public surface)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Auth file structure on disk for Antigravity accounts */
|
||||
export interface AntigravityAuthFile {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
expires_in?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
/** Auth data returned from file */
|
||||
export interface AuthData {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
projectId: string | null;
|
||||
isExpired: boolean;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** Tier info from loadCodeAssist */
|
||||
export interface TierInfo {
|
||||
id?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/** loadCodeAssist response */
|
||||
export interface LoadCodeAssistResponse {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
/** Current tier (may be trial/temporary) */
|
||||
currentTier?: TierInfo;
|
||||
/** Paid tier (reflects actual subscription - takes priority) */
|
||||
paidTier?: TierInfo;
|
||||
/** Array of allowed tiers - use isDefault=true to find active tier (CLIProxyAPIPlus approach) */
|
||||
allowedTiers?: TierInfo[];
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response model */
|
||||
export interface AvailableModel {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number;
|
||||
remaining_fraction?: number;
|
||||
remaining?: number;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
quota_info?: {
|
||||
remainingFraction?: number;
|
||||
remaining_fraction?: number;
|
||||
remaining?: number;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response */
|
||||
export interface FetchAvailableModelsResponse {
|
||||
models?: Record<string, AvailableModel>;
|
||||
}
|
||||
|
||||
export interface ManagementAuthFile {
|
||||
auth_index?: string | number;
|
||||
provider?: string;
|
||||
type?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ManagementApiCallResponse {
|
||||
status_code?: number;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ManagedResponse {
|
||||
status: number;
|
||||
bodyText: string;
|
||||
json: unknown;
|
||||
viaManagement: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectLookupResult {
|
||||
projectId: string | null;
|
||||
tier?: AccountTier;
|
||||
rawTierId?: string | null;
|
||||
rawTierLabel?: string | null;
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
actionHint?: string;
|
||||
retryable?: boolean;
|
||||
httpStatus?: number;
|
||||
needsReauth?: boolean;
|
||||
isUnprovisioned?: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user