| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| async isAvailable(): Promise<boolean> { |
| this.cachedCliPath = await findCodexCliPath(); |
| return Boolean(this.cachedCliPath); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async fetchUsageData(): Promise<CodexUsageData> { |
| 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}`); |
|
|
| |
| 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...'); |
|
|
| |
| 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'); |
|
|
| |
| return { |
| rateLimits: { |
| planType: 'unknown', |
| }, |
| lastUpdated: new Date().toISOString(), |
| }; |
| } |
|
|
| |
| |
| |
| |
| private async fetchFromAppServer(): Promise<CodexUsageData | null> { |
| try { |
| |
| if (!this.appServerService) { |
| return null; |
| } |
|
|
| |
| const [accountResult, rateLimitsResult] = await Promise.all([ |
| this.appServerService.getAccount(), |
| this.appServerService.getRateLimits(), |
| ]); |
|
|
| if (!accountResult) { |
| return null; |
| } |
|
|
| |
| |
| let planType: CodexPlanType = 'unknown'; |
|
|
| |
| const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType; |
| if (rateLimitsPlanType) { |
| const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType; |
| if (this.accountPlanTypeArray.includes(normalizedType)) { |
| planType = normalizedType; |
| } |
| } |
|
|
| |
| 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(), |
| }; |
|
|
| |
| if (rateLimitsResult?.rateLimits?.primary) { |
| const primary = rateLimitsResult.rateLimits.primary; |
| result.rateLimits!.primary = { |
| limit: -1, |
| used: -1, |
| remaining: -1, |
| usedPercent: primary.usedPercent, |
| windowDurationMins: primary.windowDurationMins, |
| resetsAt: primary.resetsAt, |
| }; |
| } |
|
|
| |
| if (rateLimitsResult?.rateLimits?.secondary) { |
| const secondary = rateLimitsResult.rateLimits.secondary; |
| result.rateLimits!.secondary = { |
| limit: -1, |
| used: -1, |
| remaining: -1, |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> { |
| 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)); |
|
|
| |
| 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<string, unknown>; |
|
|
| if (typeof openaiAuth.chatgpt_plan_type === 'string') { |
| accountType = openaiAuth.chatgpt_plan_type; |
| } |
|
|
| |
| 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 { |
| |
| 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 (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'; |
| } |
|
|
| |
| |
| |
| |
| private async fetchFromAuthFile(): Promise<CodexUsageData | null> { |
| 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; |
| } |
|
|
| |
| |
| |
| private parseJwt(token: string): Record<string, unknown> | null { |
| try { |
| const parts = token.split('.'); |
|
|
| if (parts.length !== 3) { |
| return null; |
| } |
|
|
| const base64Url = parts[1]; |
| const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); |
|
|
| |
| const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); |
|
|
| return JSON.parse(jsonPayload); |
| } catch { |
| return null; |
| } |
| } |
| } |
|
|