diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 06e7c392..dbe30a75 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -352,6 +352,28 @@ function buildAntigravityFailure( }; } +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, + }); +} + async function readManagedResponse( response: Response, viaManagement: boolean @@ -620,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + + it('attaches entitlement evidence when project lookup returns an invalid 2xx payload', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import( + `../../../src/cliproxy/quota-fetcher?agy-invalid-project=${moduleId}` + ); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-project-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('provider_unavailable'); + expect(result.entitlement).toMatchObject({ + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('preserves live tier evidence when quota fetch fails after a successful project lookup', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import( + `../../../src/cliproxy/quota-fetcher?agy-invalid-models=${moduleId}` + ); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-models-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = (async () => { + requestCount += 1; + if (requestCount === 1) { + return new Response( + JSON.stringify({ + cloudaicompanionProject: { id: 'project-x' }, + paidTier: { id: 'g1-pro-tier' }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.entitlement).toMatchObject({ + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 6a0e1bc4..a4fe2ed8 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -9,9 +9,11 @@ import type { ListAiProvidersResult, UpsertAiProviderEntryInput, } from '../../../src/cliproxy/ai-providers'; +import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types'; export const API_BASE_URL = '/api'; export const API_CONFLICT_ERROR_CODE = 'CONFLICT'; +export type { ProviderEntitlementEvidence }; export class ApiConflictError extends Error { readonly code = API_CONFLICT_ERROR_CODE; @@ -566,28 +568,6 @@ export interface QuotaResult { entitlement?: ProviderEntitlementEvidence; } -export interface ProviderEntitlementEvidence { - normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown'; - rawTierId: string | null; - rawTierLabel: string | null; - source: 'runtime_api' | 'runtime_inference' | 'registry_cache' | 'official_docs'; - confidence: 'high' | 'medium' | 'low'; - accessState: - | 'entitled' - | 'not_entitled' - | 'capacity_exhausted' - | 'temporarily_unavailable' - | 'unknown'; - capacityState: - | 'available' - | 'capacity_exhausted' - | 'rate_limited' - | 'temporarily_unavailable' - | 'unknown'; - lastVerifiedAt: number; - notes?: string | null; -} - /** Codex rate limit window */ export interface CodexQuotaWindow { /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */