mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #392 from AutoMaker-Org/fix/codex-usage-plan-detection
fix: correct Codex plan type detection from JWT auth
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import * as os from 'os';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import { checkCodexAuthentication } from '../lib/codex-auth.js';
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
findCodexCliPath,
|
||||
spawnProcess,
|
||||
getCodexAuthPath,
|
||||
systemPathExists,
|
||||
systemPathReadFile,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CodexUsage');
|
||||
|
||||
export interface CodexRateLimitWindow {
|
||||
limit: number;
|
||||
@@ -41,8 +45,6 @@ export interface CodexUsageData {
|
||||
* 2. Check for OpenAI API usage if API key is available
|
||||
*/
|
||||
export class CodexUsageService {
|
||||
private codexBinary = 'codex';
|
||||
private isWindows = os.platform() === 'win32';
|
||||
private cachedCliPath: string | null = null;
|
||||
|
||||
/**
|
||||
@@ -57,9 +59,10 @@ export class CodexUsageService {
|
||||
* Attempt to fetch usage data
|
||||
*
|
||||
* Tries multiple approaches:
|
||||
* 1. Check for OpenAI API key in environment
|
||||
* 2. Make a test request to capture rate limit headers
|
||||
* 3. Parse usage info from error responses
|
||||
* 1. Always try to get plan type from auth file first (authoritative source)
|
||||
* 2. Check for OpenAI API key in environment for API usage
|
||||
* 3. Make a test request to capture rate limit headers from CLI
|
||||
* 4. Combine results from auth file and CLI
|
||||
*/
|
||||
async fetchUsageData(): Promise<CodexUsageData> {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
|
||||
@@ -75,17 +81,21 @@ export class CodexUsageService {
|
||||
// Try to get usage from OpenAI API
|
||||
const openaiUsage = await this.fetchOpenAIUsage();
|
||||
if (openaiUsage) {
|
||||
// Merge with auth file plan type if available
|
||||
if (authPlanType && openaiUsage.rateLimits) {
|
||||
openaiUsage.rateLimits.planType = authPlanType;
|
||||
}
|
||||
return openaiUsage;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return codexUsage;
|
||||
}
|
||||
|
||||
// Fallback: try to parse usage from auth file
|
||||
// Fallback: try to parse full usage from auth file
|
||||
const authUsage = await this.fetchFromAuthFile();
|
||||
if (authUsage) {
|
||||
return authUsage;
|
||||
@@ -104,12 +114,100 @@ 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 with type validation
|
||||
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
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
|
||||
*/
|
||||
private async fetchOpenAIUsage(): Promise<CodexUsageData | null> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const endTime = Math.floor(Date.now() / 1000);
|
||||
@@ -130,7 +228,7 @@ export class CodexUsageService {
|
||||
return this.parseOpenAIUsage(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CodexUsage] Failed to fetch from OpenAI API:', error);
|
||||
logger.error('Failed to fetch from OpenAI API:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -169,7 +267,10 @@ export class CodexUsageService {
|
||||
* Try to fetch usage by making a test request to Codex CLI
|
||||
* 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 {
|
||||
// Make a simple request to trigger rate limit info if at limit
|
||||
const result = await spawnProcess({
|
||||
@@ -192,10 +293,15 @@ export class CodexUsageService {
|
||||
);
|
||||
|
||||
if (rateLimitMatch) {
|
||||
// Rate limit error contains the plan type - use that as it's the most authoritative
|
||||
const planType = rateLimitMatch[1] as CodexPlanType;
|
||||
const resetsAt = parseInt(rateLimitMatch[2], 10);
|
||||
const resetsInSeconds = parseInt(rateLimitMatch[3], 10);
|
||||
|
||||
logger.info(
|
||||
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
|
||||
);
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
@@ -212,19 +318,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 {
|
||||
rateLimits: {
|
||||
planType: 'plus',
|
||||
planType: authPlanType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: false,
|
||||
unlimited: !isFreePlan && authPlanType !== 'unknown',
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('[CodexUsage] Failed to fetch from Codex CLI:', error);
|
||||
logger.error('Failed to fetch from Codex CLI:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -232,37 +340,30 @@ export class CodexUsageService {
|
||||
|
||||
/**
|
||||
* Try to extract usage info from the Codex auth file
|
||||
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
||||
*/
|
||||
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
||||
try {
|
||||
const authFilePath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const planType = await this.getPlanTypeFromAuthFile();
|
||||
|
||||
if (fs.existsSync(authFilePath)) {
|
||||
const authContent = fs.readFileSync(authFilePath, 'utf-8');
|
||||
const authData = JSON.parse(authContent);
|
||||
|
||||
// Extract plan type from the ID token claims
|
||||
if (authData.tokens?.id_token) {
|
||||
const idToken = authData.tokens.id_token;
|
||||
const claims = this.parseJwt(idToken);
|
||||
|
||||
const planType = claims?.['https://chatgpt.com/account_type'] || 'unknown';
|
||||
const isPlus = planType === 'plus';
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: planType as CodexPlanType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isPlus,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
if (planType === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFreePlan = planType === 'free';
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isFreePlan,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('[CodexUsage] Failed to parse auth file:', error);
|
||||
logger.error('Failed to parse auth file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -273,26 +374,31 @@ export class CodexUsageService {
|
||||
*/
|
||||
private parseJwt(token: string): any {
|
||||
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 jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
|
||||
// Use Buffer for Node.js environment instead of atob
|
||||
let jsonPayload: string;
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
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);
|
||||
} catch {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user