mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +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