mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
- Add error logging to CodexProvider auth check instead of silent failure - Fix cachedAt timestamp to return actual cache time instead of request time - Replace misleading hardcoded rate limit values (100) with sentinel value (-1) - Fix unused parameter warning in codex routes Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
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<boolean> {
|
|
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<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}`);
|
|
|
|
// 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<CodexUsageData | null> {
|
|
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<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));
|
|
|
|
// 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<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() 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<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;
|
|
}
|
|
|
|
/**
|
|
* Parse JWT token to extract claims
|
|
*/
|
|
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, '/');
|
|
|
|
// Use Buffer for Node.js environment
|
|
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
|
|
|
return JSON.parse(jsonPayload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|