import { findCodexCliPath, getCodexAuthPath, systemPathExists, systemPathReadFile, } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; import type { CodexAppServerService } from './codex-app-server-service.js'; const logger = createLogger('CodexUsage'); export interface CodexRateLimitWindow { limit: number; used: number; remaining: number; usedPercent: number; windowDurationMins: number; resetsAt: number; } export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown'; export interface CodexUsageData { rateLimits: { primary?: CodexRateLimitWindow; secondary?: CodexRateLimitWindow; planType?: CodexPlanType; } | null; lastUpdated: string; } /** * Codex Usage Service * * Fetches usage data from Codex CLI using the app-server JSON-RPC API. * Falls back to auth file parsing if app-server is unavailable. */ export class CodexUsageService { private cachedCliPath: string | null = null; private appServerService: CodexAppServerService | null = null; private accountPlanTypeArray: CodexPlanType[] = [ 'free', 'plus', 'pro', 'team', 'enterprise', 'edu', ]; constructor(appServerService?: CodexAppServerService) { this.appServerService = appServerService || null; } /** * Check if Codex CLI is available on the system */ async isAvailable(): Promise { this.cachedCliPath = await findCodexCliPath(); return Boolean(this.cachedCliPath); } /** * Attempt to fetch usage data * * Priority order: * 1. Codex app-server JSON-RPC API (most reliable, provides real-time data) * 2. Auth file JWT parsing (fallback for plan type) */ async fetchUsageData(): Promise { logger.info('[fetchUsageData] Starting...'); const cliPath = this.cachedCliPath || (await findCodexCliPath()); if (!cliPath) { logger.error('[fetchUsageData] Codex CLI not found'); throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex'); } logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`); // Try to get usage from Codex app-server (most reliable method) const appServerUsage = await this.fetchFromAppServer(); if (appServerUsage) { logger.info('[fetchUsageData] ✓ Fetched usage from app-server'); return appServerUsage; } logger.info('[fetchUsageData] App-server failed, trying auth file fallback...'); // Fallback: try to parse usage from auth file const authUsage = await this.fetchFromAuthFile(); if (authUsage) { logger.info('[fetchUsageData] ✓ Fetched usage from auth file'); return authUsage; } logger.info('[fetchUsageData] All methods failed, returning unknown'); // If all else fails, return unknown return { rateLimits: { planType: 'unknown', }, lastUpdated: new Date().toISOString(), }; } /** * Fetch usage data from Codex app-server using JSON-RPC API * This is the most reliable method as it gets real-time data from OpenAI */ private async fetchFromAppServer(): Promise { try { // Use CodexAppServerService if available if (!this.appServerService) { return null; } // Fetch account and rate limits in parallel const [accountResult, rateLimitsResult] = await Promise.all([ this.appServerService.getAccount(), this.appServerService.getRateLimits(), ]); if (!accountResult) { return null; } // Build response // Prefer planType from rateLimits (more accurate/current) over account (can be stale) let planType: CodexPlanType = 'unknown'; // First try rate limits planType (most accurate) const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType; if (rateLimitsPlanType) { const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType; if (this.accountPlanTypeArray.includes(normalizedType)) { planType = normalizedType; } } // Fall back to account planType if rate limits didn't have it if (planType === 'unknown' && accountResult.account?.planType) { const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType; if (this.accountPlanTypeArray.includes(normalizedType)) { planType = normalizedType; } } const result: CodexUsageData = { rateLimits: { planType, }, lastUpdated: new Date().toISOString(), }; // Add rate limit info if available if (rateLimitsResult?.rateLimits?.primary) { const primary = rateLimitsResult.rateLimits.primary; result.rateLimits!.primary = { limit: -1, // Not provided by API used: -1, // Not provided by API remaining: -1, // Not provided by API usedPercent: primary.usedPercent, windowDurationMins: primary.windowDurationMins, resetsAt: primary.resetsAt, }; } // Add secondary rate limit if available if (rateLimitsResult?.rateLimits?.secondary) { const secondary = rateLimitsResult.rateLimits.secondary; result.rateLimits!.secondary = { limit: -1, // Not provided by API used: -1, // Not provided by API remaining: -1, // Not provided by API usedPercent: secondary.usedPercent, windowDurationMins: secondary.windowDurationMins, resetsAt: secondary.resetsAt, }; } logger.info( `[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%` ); return result; } catch (error) { logger.error('[fetchFromAppServer] Failed:', error); return null; } } /** * Extract plan type from auth file JWT token * Returns the actual plan type or 'unknown' if not available */ private async getPlanTypeFromAuthFile(): Promise { try { const authFilePath = getCodexAuthPath(); logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`); const exists = systemPathExists(authFilePath); if (!exists) { logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist'); return 'unknown'; } const authContent = await systemPathReadFile(authFilePath); const authData = JSON.parse(authContent); if (!authData.tokens?.id_token) { logger.info('[getPlanTypeFromAuthFile] No id_token in auth file'); return 'unknown'; } const claims = this.parseJwt(authData.tokens.id_token); if (!claims) { logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT'); return 'unknown'; } logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims)); // Extract plan type from nested OpenAI auth object with type validation const openaiAuthClaim = claims['https://api.openai.com/auth']; logger.info( '[getPlanTypeFromAuthFile] OpenAI auth claim:', JSON.stringify(openaiAuthClaim, null, 2) ); let accountType: string | undefined; let isSubscriptionExpired = false; if ( openaiAuthClaim && typeof openaiAuthClaim === 'object' && !Array.isArray(openaiAuthClaim) ) { const openaiAuth = openaiAuthClaim as Record; if (typeof openaiAuth.chatgpt_plan_type === 'string') { accountType = openaiAuth.chatgpt_plan_type; } // Check if subscription has expired if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') { const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until); if (!isNaN(expiryDate.getTime())) { isSubscriptionExpired = expiryDate < new Date(); } } } else { // Fallback: try top-level claim names const possibleClaimNames = [ 'https://chatgpt.com/account_type', 'account_type', 'plan', 'plan_type', ]; for (const claimName of possibleClaimNames) { const claimValue = claims[claimName]; if (claimValue && typeof claimValue === 'string') { accountType = claimValue; break; } } } // If subscription is expired, treat as free plan if (isSubscriptionExpired && accountType && accountType !== 'free') { logger.info(`Subscription expired, using "free" instead of "${accountType}"`); accountType = 'free'; } if (accountType) { const normalizedType = accountType.toLowerCase() as CodexPlanType; logger.info( `[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"` ); if (this.accountPlanTypeArray.includes(normalizedType)) { logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`); return normalizedType; } } else { logger.info('[getPlanTypeFromAuthFile] No account type found in claims'); } } catch (error) { logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error); } logger.info('[getPlanTypeFromAuthFile] Returning unknown'); return 'unknown'; } /** * Try to extract usage info from the Codex auth file * Reuses getPlanTypeFromAuthFile to avoid code duplication */ private async fetchFromAuthFile(): Promise { logger.info('[fetchFromAuthFile] Starting...'); try { const planType = await this.getPlanTypeFromAuthFile(); logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`); if (planType === 'unknown') { logger.info('[fetchFromAuthFile] Plan type unknown, returning null'); return null; } const result: CodexUsageData = { rateLimits: { planType, }, lastUpdated: new Date().toISOString(), }; logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2)); return result; } catch (error) { logger.error('[fetchFromAuthFile] Failed to parse auth file:', error); } return null; } /** * Parse JWT token to extract claims */ private parseJwt(token: string): Record | null { try { const parts = token.split('.'); if (parts.length !== 3) { return null; } const base64Url = parts[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); // Use Buffer for Node.js environment const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); return JSON.parse(jsonPayload); } catch { return null; } } }