fix: correct Codex plan type detection from JWT auth

- Fix hardcoded 'plus' planType that was returned as default
- Read plan type from correct JWT path: https://api.openai.com/auth.chatgpt_plan_type
- Add subscription expiry check - override to 'free' if expired
- Use getCodexAuthPath() from @automaker/platform instead of manual path
- Remove unused imports (os, fs, path) and class properties
- Clean up code and add minimal essential logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2026-01-09 22:18:41 +01:00
parent 93807c22c1
commit 5d0fb08651

View File

@@ -1,9 +1,13 @@
import * as os from 'os'; import {
import { findCodexCliPath } from '@automaker/platform'; findCodexCliPath,
import { checkCodexAuthentication } from '../lib/codex-auth.js'; spawnProcess,
import { spawnProcess } from '@automaker/platform'; getCodexAuthPath,
import * as fs from 'fs'; systemPathExists,
import * as path from 'path'; systemPathReadFile,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CodexUsage');
export interface CodexRateLimitWindow { export interface CodexRateLimitWindow {
limit: number; limit: number;
@@ -41,8 +45,6 @@ export interface CodexUsageData {
* 2. Check for OpenAI API usage if API key is available * 2. Check for OpenAI API usage if API key is available
*/ */
export class CodexUsageService { export class CodexUsageService {
private codexBinary = 'codex';
private isWindows = os.platform() === 'win32';
private cachedCliPath: string | null = null; private cachedCliPath: string | null = null;
/** /**
@@ -57,9 +59,10 @@ export class CodexUsageService {
* Attempt to fetch usage data * Attempt to fetch usage data
* *
* Tries multiple approaches: * Tries multiple approaches:
* 1. Check for OpenAI API key in environment * 1. Always try to get plan type from auth file first (authoritative source)
* 2. Make a test request to capture rate limit headers * 2. Check for OpenAI API key in environment for API usage
* 3. Parse usage info from error responses * 3. Make a test request to capture rate limit headers from CLI
* 4. Combine results from auth file and CLI
*/ */
async fetchUsageData(): Promise<CodexUsageData> { async fetchUsageData(): Promise<CodexUsageData> {
const cliPath = this.cachedCliPath || (await findCodexCliPath()); const cliPath = this.cachedCliPath || (await findCodexCliPath());
@@ -68,6 +71,9 @@ export class CodexUsageService {
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex'); throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
} }
// Always try to get plan type from auth file first - this is the authoritative source
const authPlanType = await this.getPlanTypeFromAuthFile();
// Check if user has an API key that we can use // Check if user has an API key that we can use
const hasApiKey = !!process.env.OPENAI_API_KEY; const hasApiKey = !!process.env.OPENAI_API_KEY;
@@ -75,17 +81,21 @@ export class CodexUsageService {
// Try to get usage from OpenAI API // Try to get usage from OpenAI API
const openaiUsage = await this.fetchOpenAIUsage(); const openaiUsage = await this.fetchOpenAIUsage();
if (openaiUsage) { if (openaiUsage) {
// Merge with auth file plan type if available
if (authPlanType && openaiUsage.rateLimits) {
openaiUsage.rateLimits.planType = authPlanType;
}
return openaiUsage; return openaiUsage;
} }
} }
// Try to get usage from Codex CLI by making a simple request // Try to get usage from Codex CLI by making a simple request
const codexUsage = await this.fetchCodexUsage(cliPath); const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
if (codexUsage) { if (codexUsage) {
return codexUsage; return codexUsage;
} }
// Fallback: try to parse usage from auth file // Fallback: try to parse full usage from auth file
const authUsage = await this.fetchFromAuthFile(); const authUsage = await this.fetchFromAuthFile();
if (authUsage) { if (authUsage) {
return authUsage; return authUsage;
@@ -104,12 +114,94 @@ export class CodexUsageService {
); );
} }
/**
* Extract plan type from auth file JWT token
* Returns the actual plan type or 'unknown' if not available
*/
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
try {
const authFilePath = getCodexAuthPath();
const exists = await systemPathExists(authFilePath);
if (!exists) {
return 'unknown';
}
const authContent = await systemPathReadFile(authFilePath);
const authData = JSON.parse(authContent);
if (!authData.tokens?.id_token) {
return 'unknown';
}
const claims = this.parseJwt(authData.tokens.id_token);
if (!claims) {
return 'unknown';
}
// Extract plan type from nested OpenAI auth object
const openaiAuth = claims['https://api.openai.com/auth'] as
| {
chatgpt_plan_type?: string;
chatgpt_subscription_active_until?: string;
}
| undefined;
let accountType: string | undefined;
let isSubscriptionExpired = false;
if (openaiAuth) {
accountType = openaiAuth.chatgpt_plan_type;
// Check if subscription has expired
if (openaiAuth.chatgpt_subscription_active_until) {
const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until);
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) {
if (claims[claimName]) {
accountType = claims[claimName];
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();
if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) {
return normalizedType as CodexPlanType;
}
}
} catch (error) {
logger.error('Failed to get plan type from auth file:', error);
}
return 'unknown';
}
/** /**
* Try to fetch usage from OpenAI API using the API key * Try to fetch usage from OpenAI API using the API key
*/ */
private async fetchOpenAIUsage(): Promise<CodexUsageData | null> { private async fetchOpenAIUsage(): Promise<CodexUsageData | null> {
const apiKey = process.env.OPENAI_API_KEY; const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return null; if (!apiKey) {
return null;
}
try { try {
const endTime = Math.floor(Date.now() / 1000); const endTime = Math.floor(Date.now() / 1000);
@@ -130,7 +222,7 @@ export class CodexUsageService {
return this.parseOpenAIUsage(data); return this.parseOpenAIUsage(data);
} }
} catch (error) { } catch (error) {
console.log('[CodexUsage] Failed to fetch from OpenAI API:', error); logger.error('Failed to fetch from OpenAI API:', error);
} }
return null; return null;
@@ -169,7 +261,10 @@ export class CodexUsageService {
* Try to fetch usage by making a test request to Codex CLI * Try to fetch usage by making a test request to Codex CLI
* and parsing rate limit information from the response * and parsing rate limit information from the response
*/ */
private async fetchCodexUsage(cliPath: string): Promise<CodexUsageData | null> { private async fetchCodexUsage(
cliPath: string,
authPlanType: CodexPlanType
): Promise<CodexUsageData | null> {
try { try {
// Make a simple request to trigger rate limit info if at limit // Make a simple request to trigger rate limit info if at limit
const result = await spawnProcess({ const result = await spawnProcess({
@@ -192,10 +287,15 @@ export class CodexUsageService {
); );
if (rateLimitMatch) { if (rateLimitMatch) {
// Rate limit error contains the plan type - use that as it's the most authoritative
const planType = rateLimitMatch[1] as CodexPlanType; const planType = rateLimitMatch[1] as CodexPlanType;
const resetsAt = parseInt(rateLimitMatch[2], 10); const resetsAt = parseInt(rateLimitMatch[2], 10);
const resetsInSeconds = parseInt(rateLimitMatch[3], 10); const resetsInSeconds = parseInt(rateLimitMatch[3], 10);
logger.info(
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
);
return { return {
rateLimits: { rateLimits: {
planType, planType,
@@ -212,19 +312,21 @@ export class CodexUsageService {
}; };
} }
// If no rate limit, return basic info // No rate limit error - use the plan type from auth file
const isFreePlan = authPlanType === 'free';
return { return {
rateLimits: { rateLimits: {
planType: 'plus', planType: authPlanType,
credits: { credits: {
hasCredits: true, hasCredits: true,
unlimited: false, unlimited: !isFreePlan && authPlanType !== 'unknown',
}, },
}, },
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} catch (error) { } catch (error) {
console.log('[CodexUsage] Failed to fetch from Codex CLI:', error); logger.error('Failed to fetch from Codex CLI:', error);
} }
return null; return null;
@@ -235,34 +337,49 @@ export class CodexUsageService {
*/ */
private async fetchFromAuthFile(): Promise<CodexUsageData | null> { private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
try { try {
const authFilePath = path.join(os.homedir(), '.codex', 'auth.json'); const authFilePath = getCodexAuthPath();
if (fs.existsSync(authFilePath)) { if (!(await systemPathExists(authFilePath))) {
const authContent = fs.readFileSync(authFilePath, 'utf-8'); return null;
const authData = JSON.parse(authContent); }
// Extract plan type from the ID token claims const authContent = await systemPathReadFile(authFilePath);
if (authData.tokens?.id_token) { const authData = JSON.parse(authContent);
const idToken = authData.tokens.id_token;
const claims = this.parseJwt(idToken);
const planType = claims?.['https://chatgpt.com/account_type'] || 'unknown'; if (!authData.tokens?.id_token) {
const isPlus = planType === 'plus'; return null;
}
return { const claims = this.parseJwt(authData.tokens.id_token);
rateLimits: { if (!claims) {
planType: planType as CodexPlanType, return null;
credits: { }
hasCredits: true,
unlimited: !isPlus, const accountType = claims?.['https://chatgpt.com/account_type'];
},
}, // Normalize to our plan types
lastUpdated: new Date().toISOString(), let planType: CodexPlanType = 'unknown';
}; if (accountType) {
const normalizedType = accountType.toLowerCase();
if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) {
planType = normalizedType as CodexPlanType;
} }
} }
const isFreePlan = planType === 'free';
return {
rateLimits: {
planType,
credits: {
hasCredits: true,
unlimited: !isFreePlan && planType !== 'unknown',
},
},
lastUpdated: new Date().toISOString(),
};
} catch (error) { } catch (error) {
console.log('[CodexUsage] Failed to parse auth file:', error); logger.error('Failed to parse auth file:', error);
} }
return null; return null;
@@ -273,26 +390,31 @@ export class CodexUsageService {
*/ */
private parseJwt(token: string): any { private parseJwt(token: string): any {
try { try {
const base64Url = token.split('.')[1]; const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64) // Use Buffer for Node.js environment instead of atob
.split('') let jsonPayload: string;
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) if (typeof Buffer !== 'undefined') {
.join('') jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
); } else {
jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
}
return JSON.parse(jsonPayload); return JSON.parse(jsonPayload);
} catch { } catch {
return null; return null;
} }
} }
/**
* Check if Codex is authenticated
*/
private async checkAuthentication(): Promise<boolean> {
const cliPath = this.cachedCliPath || (await findCodexCliPath());
const authCheck = await checkCodexAuthentication(cliPath);
return authCheck.authenticated;
}
} }