mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
feat: enhance CodexUsageService to fetch usage data from app-server JSON-RPC API
- Implemented a new method to retrieve usage data from the Codex app-server, providing real-time data and improving reliability. - Updated the fetchUsageData method to prioritize app-server data over fallback methods. - Added detailed logging for better traceability and debugging. - Removed unused methods related to OpenAI API usage and Codex CLI requests, streamlining the service. These changes enhance the functionality and robustness of the CodexUsageService, ensuring accurate usage statistics retrieval.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import readline from 'readline';
|
||||||
import {
|
import {
|
||||||
findCodexCliPath,
|
findCodexCliPath,
|
||||||
spawnProcess,
|
|
||||||
getCodexAuthPath,
|
getCodexAuthPath,
|
||||||
systemPathExists,
|
systemPathExists,
|
||||||
systemPathReadFile,
|
systemPathReadFile,
|
||||||
@@ -36,17 +37,51 @@ export interface CodexUsageData {
|
|||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC response types from Codex app-server
|
||||||
|
*/
|
||||||
|
interface AppServerAccountResponse {
|
||||||
|
account: {
|
||||||
|
type: 'apiKey' | 'chatgpt';
|
||||||
|
email?: string;
|
||||||
|
planType?: string;
|
||||||
|
} | null;
|
||||||
|
requiresOpenaiAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppServerRateLimitsResponse {
|
||||||
|
rateLimits: {
|
||||||
|
primary: {
|
||||||
|
usedPercent: number;
|
||||||
|
windowDurationMins: number;
|
||||||
|
resetsAt: number;
|
||||||
|
} | null;
|
||||||
|
secondary: {
|
||||||
|
usedPercent: number;
|
||||||
|
windowDurationMins: number;
|
||||||
|
resetsAt: number;
|
||||||
|
} | null;
|
||||||
|
credits?: unknown;
|
||||||
|
planType?: string; // This is the most accurate/current plan type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex Usage Service
|
* Codex Usage Service
|
||||||
*
|
*
|
||||||
* Attempts to fetch usage data from Codex CLI and OpenAI API.
|
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
|
||||||
* Codex CLI doesn't provide a direct usage command, but we can:
|
* Falls back to auth file parsing if app-server is unavailable.
|
||||||
* 1. Parse usage info from error responses (rate limit errors contain plan info)
|
|
||||||
* 2. Check for OpenAI API usage if API key is available
|
|
||||||
*/
|
*/
|
||||||
export class CodexUsageService {
|
export class CodexUsageService {
|
||||||
private cachedCliPath: string | null = null;
|
private cachedCliPath: string | null = null;
|
||||||
|
private accountPlanTypeArray: CodexPlanType[] = [
|
||||||
|
'free',
|
||||||
|
'plus',
|
||||||
|
'pro',
|
||||||
|
'team',
|
||||||
|
'enterprise',
|
||||||
|
'edu',
|
||||||
|
];
|
||||||
/**
|
/**
|
||||||
* Check if Codex CLI is available on the system
|
* Check if Codex CLI is available on the system
|
||||||
*/
|
*/
|
||||||
@@ -58,60 +93,283 @@ export class CodexUsageService {
|
|||||||
/**
|
/**
|
||||||
* Attempt to fetch usage data
|
* Attempt to fetch usage data
|
||||||
*
|
*
|
||||||
* Tries multiple approaches:
|
* Priority order:
|
||||||
* 1. Always try to get plan type from auth file first (authoritative source)
|
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
|
||||||
* 2. Check for OpenAI API key in environment for API usage
|
* 2. Auth file JWT parsing (fallback for plan type)
|
||||||
* 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> {
|
||||||
|
logger.info('[fetchUsageData] Starting...');
|
||||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||||
|
|
||||||
if (!cliPath) {
|
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');
|
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
|
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
|
||||||
const authPlanType = await this.getPlanTypeFromAuthFile();
|
|
||||||
|
|
||||||
// Check if user has an API key that we can use
|
// Try to get usage from Codex app-server (most reliable method)
|
||||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
const appServerUsage = await this.fetchFromAppServer(cliPath);
|
||||||
|
if (appServerUsage) {
|
||||||
if (hasApiKey) {
|
logger.info(
|
||||||
// Try to get usage from OpenAI API
|
'[fetchUsageData] Got data from app-server:',
|
||||||
const openaiUsage = await this.fetchOpenAIUsage();
|
JSON.stringify(appServerUsage, null, 2)
|
||||||
if (openaiUsage) {
|
);
|
||||||
// Merge with auth file plan type if available
|
return appServerUsage;
|
||||||
if (authPlanType && openaiUsage.rateLimits) {
|
|
||||||
openaiUsage.rateLimits.planType = authPlanType;
|
|
||||||
}
|
|
||||||
return openaiUsage;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get usage from Codex CLI by making a simple request
|
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
|
||||||
const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
|
|
||||||
if (codexUsage) {
|
|
||||||
return codexUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try to parse full usage from auth file
|
// Fallback: try to parse usage from auth file
|
||||||
const authUsage = await this.fetchFromAuthFile();
|
const authUsage = await this.fetchFromAuthFile();
|
||||||
if (authUsage) {
|
if (authUsage) {
|
||||||
|
logger.info('[fetchUsageData] Got data from auth file:', JSON.stringify(authUsage, null, 2));
|
||||||
return authUsage;
|
return authUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If all else fails, return a message with helpful information
|
logger.info('[fetchUsageData] All methods failed, returning unknown');
|
||||||
throw new Error(
|
|
||||||
'Codex usage statistics require additional configuration. ' +
|
// If all else fails, return unknown
|
||||||
'To enable usage tracking:\n\n' +
|
return {
|
||||||
'1. Set your OpenAI API key in the environment:\n' +
|
rateLimits: {
|
||||||
' export OPENAI_API_KEY=sk-...\n\n' +
|
planType: 'unknown',
|
||||||
'2. Or check your usage at:\n' +
|
credits: {
|
||||||
' https://platform.openai.com/usage\n\n' +
|
hasCredits: true,
|
||||||
'Note: If using Codex CLI with ChatGPT OAuth authentication, ' +
|
},
|
||||||
'usage data must be queried through your OpenAI account.'
|
},
|
||||||
);
|
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(cliPath: string): Promise<CodexUsageData | null> {
|
||||||
|
let childProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// On Windows, .cmd files must be run through shell
|
||||||
|
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
|
||||||
|
|
||||||
|
childProcess = spawn(cliPath, ['app-server'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'dumb',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: needsShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childProcess.stdin || !childProcess.stdout) {
|
||||||
|
throw new Error('Failed to create stdio pipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup readline for reading JSONL responses
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: childProcess.stdout,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message ID counter for JSON-RPC
|
||||||
|
let messageId = 0;
|
||||||
|
const pendingRequests = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Process incoming messages
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
|
||||||
|
// Handle response to our request
|
||||||
|
if ('id' in message && message.id !== undefined) {
|
||||||
|
const pending = pendingRequests.get(message.id);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingRequests.delete(message.id);
|
||||||
|
if (message.error) {
|
||||||
|
pending.reject(new Error(message.error.message || 'Unknown error'));
|
||||||
|
} else {
|
||||||
|
pending.resolve(message.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore notifications (no id field)
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors for non-JSON lines
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to send JSON-RPC request and wait for response
|
||||||
|
const sendRequest = <T>(method: string, params?: unknown): Promise<T> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++messageId;
|
||||||
|
const request = params ? { method, id, params } : { method, id };
|
||||||
|
|
||||||
|
// Set timeout for request
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRequests.delete(id);
|
||||||
|
reject(new Error(`Request timeout: ${method}`));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
pendingRequests.set(id, {
|
||||||
|
resolve: resolve as (value: unknown) => void,
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to send notification (no response expected)
|
||||||
|
const sendNotification = (method: string, params?: unknown): void => {
|
||||||
|
const notification = params ? { method, params } : { method };
|
||||||
|
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Initialize the app-server
|
||||||
|
logger.info('[fetchFromAppServer] Sending initialize request...');
|
||||||
|
const initResult = await sendRequest('initialize', {
|
||||||
|
clientInfo: {
|
||||||
|
name: 'automaker',
|
||||||
|
title: 'AutoMaker',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info('[fetchFromAppServer] Initialize result:', JSON.stringify(initResult, null, 2));
|
||||||
|
|
||||||
|
// 2. Send initialized notification
|
||||||
|
sendNotification('initialized');
|
||||||
|
logger.info('[fetchFromAppServer] Sent initialized notification');
|
||||||
|
|
||||||
|
// 3. Get account info (includes plan type)
|
||||||
|
logger.info('[fetchFromAppServer] Requesting account/read...');
|
||||||
|
const accountResult = await sendRequest<AppServerAccountResponse>('account/read', {
|
||||||
|
refreshToken: false,
|
||||||
|
});
|
||||||
|
logger.info('[fetchFromAppServer] Account result:', JSON.stringify(accountResult, null, 2));
|
||||||
|
|
||||||
|
// 4. Get rate limits
|
||||||
|
let rateLimitsResult: AppServerRateLimitsResponse | null = null;
|
||||||
|
try {
|
||||||
|
logger.info('[fetchFromAppServer] Requesting account/rateLimits/read...');
|
||||||
|
rateLimitsResult =
|
||||||
|
await sendRequest<AppServerRateLimitsResponse>('account/rateLimits/read');
|
||||||
|
logger.info(
|
||||||
|
'[fetchFromAppServer] Rate limits result:',
|
||||||
|
JSON.stringify(rateLimitsResult, null, 2)
|
||||||
|
);
|
||||||
|
} catch (rateLimitError) {
|
||||||
|
// Rate limits may not be available for API key auth
|
||||||
|
logger.info('[fetchFromAppServer] Rate limits not available:', rateLimitError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
rl.close();
|
||||||
|
childProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
logger.info(
|
||||||
|
`[fetchFromAppServer] Rate limits planType: "${rateLimitsPlanType}", normalized: "${normalizedType}"`
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
logger.info(
|
||||||
|
`[fetchFromAppServer] Fallback to account planType: "${accountResult.account.planType}", normalized: "${normalizedType}"`
|
||||||
|
);
|
||||||
|
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||||
|
planType = normalizedType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planType === 'unknown') {
|
||||||
|
logger.info('[fetchFromAppServer] No planType found in either response');
|
||||||
|
} else {
|
||||||
|
logger.info(`[fetchFromAppServer] Final planType: ${planType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CodexUsageData = {
|
||||||
|
rateLimits: {
|
||||||
|
planType,
|
||||||
|
credits: {
|
||||||
|
hasCredits: true,
|
||||||
|
unlimited: planType !== 'free' && planType !== 'unknown',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add rate limit info if available
|
||||||
|
if (rateLimitsResult?.rateLimits?.primary) {
|
||||||
|
const primary = rateLimitsResult.rateLimits.primary;
|
||||||
|
logger.info(
|
||||||
|
'[fetchFromAppServer] Adding primary rate limit:',
|
||||||
|
JSON.stringify(primary, null, 2)
|
||||||
|
);
|
||||||
|
result.rateLimits!.primary = {
|
||||||
|
limit: 100, // Not provided by API, using placeholder
|
||||||
|
used: primary.usedPercent,
|
||||||
|
remaining: 100 - primary.usedPercent,
|
||||||
|
usedPercent: primary.usedPercent,
|
||||||
|
windowDurationMins: primary.windowDurationMins,
|
||||||
|
resetsAt: primary.resetsAt,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.info('[fetchFromAppServer] No primary rate limit in result');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add secondary rate limit if available
|
||||||
|
if (rateLimitsResult?.rateLimits?.secondary) {
|
||||||
|
const secondary = rateLimitsResult.rateLimits.secondary;
|
||||||
|
logger.info(
|
||||||
|
'[fetchFromAppServer] Adding secondary rate limit:',
|
||||||
|
JSON.stringify(secondary, null, 2)
|
||||||
|
);
|
||||||
|
result.rateLimits!.secondary = {
|
||||||
|
limit: 100,
|
||||||
|
used: secondary.usedPercent,
|
||||||
|
remaining: 100 - secondary.usedPercent,
|
||||||
|
usedPercent: secondary.usedPercent,
|
||||||
|
windowDurationMins: secondary.windowDurationMins,
|
||||||
|
resetsAt: secondary.resetsAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[fetchFromAppServer] Final result:', JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// App-server method failed, will fall back to other methods
|
||||||
|
logger.error('Failed to fetch from app-server:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Ensure process is killed
|
||||||
|
if (childProcess && !childProcess.killed) {
|
||||||
|
childProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,9 +379,11 @@ export class CodexUsageService {
|
|||||||
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
||||||
try {
|
try {
|
||||||
const authFilePath = getCodexAuthPath();
|
const authFilePath = getCodexAuthPath();
|
||||||
const exists = await systemPathExists(authFilePath);
|
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
|
||||||
|
const exists = systemPathExists(authFilePath);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] Auth file does not exist');
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,16 +391,24 @@ export class CodexUsageService {
|
|||||||
const authData = JSON.parse(authContent);
|
const authData = JSON.parse(authContent);
|
||||||
|
|
||||||
if (!authData.tokens?.id_token) {
|
if (!authData.tokens?.id_token) {
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
const claims = this.parseJwt(authData.tokens.id_token);
|
const claims = this.parseJwt(authData.tokens.id_token);
|
||||||
if (!claims) {
|
if (!claims) {
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
|
||||||
|
|
||||||
// Extract plan type from nested OpenAI auth object with type validation
|
// Extract plan type from nested OpenAI auth object with type validation
|
||||||
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
||||||
|
logger.info(
|
||||||
|
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
|
||||||
|
JSON.stringify(openaiAuthClaim, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
let accountType: string | undefined;
|
let accountType: string | undefined;
|
||||||
let isSubscriptionExpired = false;
|
let isSubscriptionExpired = false;
|
||||||
@@ -188,154 +456,23 @@ export class CodexUsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (accountType) {
|
if (accountType) {
|
||||||
const normalizedType = accountType.toLowerCase();
|
const normalizedType = accountType.toLowerCase() as CodexPlanType;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endTime = Math.floor(Date.now() / 1000);
|
|
||||||
const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
return this.parseOpenAIUsage(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch from OpenAI API:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse OpenAI usage API response
|
|
||||||
*/
|
|
||||||
private parseOpenAIUsage(data: any): CodexUsageData {
|
|
||||||
let totalInputTokens = 0;
|
|
||||||
let totalOutputTokens = 0;
|
|
||||||
|
|
||||||
if (data.data && Array.isArray(data.data)) {
|
|
||||||
for (const bucket of data.data) {
|
|
||||||
if (bucket.results && Array.isArray(bucket.results)) {
|
|
||||||
for (const result of bucket.results) {
|
|
||||||
totalInputTokens += result.input_tokens || 0;
|
|
||||||
totalOutputTokens += result.output_tokens || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rateLimits: {
|
|
||||||
planType: 'unknown',
|
|
||||||
credits: {
|
|
||||||
hasCredits: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
authPlanType: CodexPlanType
|
|
||||||
): Promise<CodexUsageData | null> {
|
|
||||||
try {
|
|
||||||
// Make a simple request to trigger rate limit info if at limit
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: cliPath,
|
|
||||||
args: ['exec', '--', 'echo', 'test'],
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'dumb',
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse the output for rate limit information
|
|
||||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
|
||||||
|
|
||||||
// Check if we got a rate limit error
|
|
||||||
const rateLimitMatch = combinedOutput.match(
|
|
||||||
/usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
logger.info(
|
||||||
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
|
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
|
||||||
);
|
);
|
||||||
|
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||||
return {
|
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
|
||||||
rateLimits: {
|
return normalizedType;
|
||||||
planType,
|
}
|
||||||
primary: {
|
} else {
|
||||||
limit: 0,
|
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
|
||||||
used: 0,
|
|
||||||
remaining: 0,
|
|
||||||
usedPercent: 100,
|
|
||||||
windowDurationMins: Math.ceil(resetsInSeconds / 60),
|
|
||||||
resetsAt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No rate limit error - use the plan type from auth file
|
|
||||||
const isFreePlan = authPlanType === 'free';
|
|
||||||
|
|
||||||
return {
|
|
||||||
rateLimits: {
|
|
||||||
planType: authPlanType,
|
|
||||||
credits: {
|
|
||||||
hasCredits: true,
|
|
||||||
unlimited: !isFreePlan && authPlanType !== 'unknown',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch from Codex CLI:', error);
|
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
|
||||||
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,16 +480,19 @@ export class CodexUsageService {
|
|||||||
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
||||||
*/
|
*/
|
||||||
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
||||||
|
logger.info('[fetchFromAuthFile] Starting...');
|
||||||
try {
|
try {
|
||||||
const planType = await this.getPlanTypeFromAuthFile();
|
const planType = await this.getPlanTypeFromAuthFile();
|
||||||
|
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
|
||||||
|
|
||||||
if (planType === 'unknown') {
|
if (planType === 'unknown') {
|
||||||
|
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFreePlan = planType === 'free';
|
const isFreePlan = planType === 'free';
|
||||||
|
|
||||||
return {
|
const result: CodexUsageData = {
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
planType,
|
planType,
|
||||||
credits: {
|
credits: {
|
||||||
@@ -362,8 +502,11 @@ export class CodexUsageService {
|
|||||||
},
|
},
|
||||||
lastUpdated: new Date().toISOString(),
|
lastUpdated: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to parse auth file:', error);
|
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -372,7 +515,7 @@ export class CodexUsageService {
|
|||||||
/**
|
/**
|
||||||
* Parse JWT token to extract claims
|
* Parse JWT token to extract claims
|
||||||
*/
|
*/
|
||||||
private parseJwt(token: string): any {
|
private parseJwt(token: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
|
|
||||||
@@ -383,18 +526,8 @@ export class CodexUsageService {
|
|||||||
const base64Url = parts[1];
|
const base64Url = parts[1];
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
|
||||||
// Use Buffer for Node.js environment instead of atob
|
// Use Buffer for Node.js environment
|
||||||
let jsonPayload: string;
|
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||||
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);
|
return JSON.parse(jsonPayload);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user