mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge pull request #400 from AutoMaker-Org/feat/codex-usage
feat: improve codex plan and usage detection
This commit is contained in:
@@ -55,6 +55,8 @@ import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createCodexRoutes } from './routes/codex/index.js';
|
||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
@@ -168,7 +170,9 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const codexUsageService = new CodexUsageService();
|
||||
const codexAppServerService = new CodexAppServerService();
|
||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
@@ -176,6 +180,11 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
logger.info('Agent service initialized');
|
||||
|
||||
// Bootstrap Codex model cache in background (don't block server startup)
|
||||
void codexModelCacheService.getModels().catch((err) => {
|
||||
logger.error('Failed to bootstrap Codex model cache:', err);
|
||||
});
|
||||
})();
|
||||
|
||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||
@@ -219,7 +228,7 @@ app.use('/api/templates', createTemplatesRoutes());
|
||||
app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* Never assumes authenticated - only returns true if CLI confirms.
|
||||
*/
|
||||
|
||||
import { spawnProcess, getCodexAuthPath } from '@automaker/platform';
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import * as fs from 'fs';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CodexAuth');
|
||||
|
||||
const CODEX_COMMAND = 'codex';
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
@@ -26,36 +28,16 @@ export interface CodexAuthCheckResult {
|
||||
export async function checkCodexAuthentication(
|
||||
cliPath?: string | null
|
||||
): Promise<CodexAuthCheckResult> {
|
||||
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
|
||||
|
||||
const resolvedCliPath = cliPath || (await findCodexCliPath());
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
|
||||
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
|
||||
console.log('[CodexAuth] hasApiKey:', hasApiKey);
|
||||
|
||||
// Debug: Check auth file
|
||||
const authFilePath = getCodexAuthPath();
|
||||
console.log('[CodexAuth] Auth file path:', authFilePath);
|
||||
try {
|
||||
const authFileExists = fs.existsSync(authFilePath);
|
||||
console.log('[CodexAuth] Auth file exists:', authFileExists);
|
||||
if (authFileExists) {
|
||||
const authContent = fs.readFileSync(authFilePath, 'utf-8');
|
||||
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CodexAuth] Error reading auth file:', error);
|
||||
}
|
||||
|
||||
// If CLI is not installed, cannot be authenticated
|
||||
if (!resolvedCliPath) {
|
||||
console.log('[CodexAuth] No CLI path found, returning not authenticated');
|
||||
logger.info('CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
|
||||
const result = await spawnProcess({
|
||||
command: resolvedCliPath || CODEX_COMMAND,
|
||||
args: ['login', 'status'],
|
||||
@@ -66,33 +48,21 @@ export async function checkCodexAuthentication(
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[CodexAuth] Command result:');
|
||||
console.log('[CodexAuth] exitCode:', result.exitCode);
|
||||
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
|
||||
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
|
||||
|
||||
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
const isLoggedIn = combinedOutput.includes('logged in');
|
||||
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
|
||||
|
||||
if (result.exitCode === 0 && isLoggedIn) {
|
||||
// Determine auth method based on what we know
|
||||
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
console.log('[CodexAuth] Authenticated! method:', method);
|
||||
logger.info(`✓ Authenticated (${method})`);
|
||||
return { authenticated: true, method };
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[CodexAuth] Not authenticated. exitCode:',
|
||||
result.exitCode,
|
||||
'isLoggedIn:',
|
||||
isLoggedIn
|
||||
);
|
||||
logger.info('Not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
} catch (error) {
|
||||
console.log('[CodexAuth] Error running command:', error);
|
||||
logger.error('Failed to check authentication:', error);
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
console.log('[CodexAuth] Returning not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
extractTextFromContent,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
@@ -658,6 +659,8 @@ async function loadCodexInstructions(cwd: string, enabled: boolean): Promise<str
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
const logger = createLogger('CodexProvider');
|
||||
|
||||
export class CodexProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'codex';
|
||||
@@ -967,21 +970,11 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
console.log('[CodexProvider.detectInstallation] Starting...');
|
||||
|
||||
const cliPath = await findCodexCliPath();
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
const installed = !!cliPath;
|
||||
|
||||
console.log('[CodexProvider.detectInstallation] cliPath:', cliPath);
|
||||
console.log('[CodexProvider.detectInstallation] hasApiKey:', hasApiKey);
|
||||
console.log(
|
||||
'[CodexProvider.detectInstallation] authIndicators:',
|
||||
JSON.stringify(authIndicators)
|
||||
);
|
||||
console.log('[CodexProvider.detectInstallation] installed:', installed);
|
||||
|
||||
let version = '';
|
||||
if (installed) {
|
||||
try {
|
||||
@@ -991,20 +984,16 @@ export class CodexProvider extends BaseProvider {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
version = result.stdout.trim();
|
||||
console.log('[CodexProvider.detectInstallation] version:', version);
|
||||
} catch (error) {
|
||||
console.log('[CodexProvider.detectInstallation] Error getting version:', error);
|
||||
version = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Determine auth status - always verify with CLI, never assume authenticated
|
||||
console.log('[CodexProvider.detectInstallation] Calling checkCodexAuthentication...');
|
||||
const authCheck = await checkCodexAuthentication(cliPath);
|
||||
console.log('[CodexProvider.detectInstallation] authCheck result:', JSON.stringify(authCheck));
|
||||
const authenticated = authCheck.authenticated;
|
||||
|
||||
const result = {
|
||||
return {
|
||||
installed,
|
||||
path: cliPath || undefined,
|
||||
version: version || undefined,
|
||||
@@ -1012,8 +1001,6 @@ export class CodexProvider extends BaseProvider {
|
||||
hasApiKey,
|
||||
authenticated,
|
||||
};
|
||||
console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
@@ -1025,36 +1012,24 @@ export class CodexProvider extends BaseProvider {
|
||||
* Check authentication status for Codex CLI
|
||||
*/
|
||||
async checkAuth(): Promise<CodexAuthStatus> {
|
||||
console.log('[CodexProvider.checkAuth] Starting auth check...');
|
||||
|
||||
const cliPath = await findCodexCliPath();
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
|
||||
console.log('[CodexProvider.checkAuth] cliPath:', cliPath);
|
||||
console.log('[CodexProvider.checkAuth] hasApiKey:', hasApiKey);
|
||||
console.log('[CodexProvider.checkAuth] authIndicators:', JSON.stringify(authIndicators));
|
||||
|
||||
// Check for API key in environment
|
||||
if (hasApiKey) {
|
||||
console.log('[CodexProvider.checkAuth] Has API key, returning authenticated');
|
||||
return { authenticated: true, method: 'api_key' };
|
||||
}
|
||||
|
||||
// Check for OAuth/token from Codex CLI
|
||||
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
|
||||
console.log(
|
||||
'[CodexProvider.checkAuth] Has OAuth token or API key in auth file, returning authenticated'
|
||||
);
|
||||
return { authenticated: true, method: 'oauth' };
|
||||
}
|
||||
|
||||
// CLI is installed but not authenticated via indicators - try CLI command
|
||||
console.log('[CodexProvider.checkAuth] No indicators found, trying CLI command...');
|
||||
if (cliPath) {
|
||||
try {
|
||||
// Try 'codex login status' first (same as checkCodexAuthentication)
|
||||
console.log('[CodexProvider.checkAuth] Running: ' + cliPath + ' login status');
|
||||
const result = await spawnProcess({
|
||||
command: cliPath || CODEX_COMMAND,
|
||||
args: ['login', 'status'],
|
||||
@@ -1064,26 +1039,19 @@ export class CodexProvider extends BaseProvider {
|
||||
TERM: 'dumb',
|
||||
},
|
||||
});
|
||||
console.log('[CodexProvider.checkAuth] login status result:');
|
||||
console.log('[CodexProvider.checkAuth] exitCode:', result.exitCode);
|
||||
console.log('[CodexProvider.checkAuth] stdout:', JSON.stringify(result.stdout));
|
||||
console.log('[CodexProvider.checkAuth] stderr:', JSON.stringify(result.stderr));
|
||||
|
||||
// Check both stdout and stderr - Codex CLI outputs to stderr
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
const isLoggedIn = combinedOutput.includes('logged in');
|
||||
console.log('[CodexProvider.checkAuth] isLoggedIn:', isLoggedIn);
|
||||
|
||||
if (result.exitCode === 0 && isLoggedIn) {
|
||||
console.log('[CodexProvider.checkAuth] CLI says logged in, returning authenticated');
|
||||
return { authenticated: true, method: 'oauth' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CodexProvider.checkAuth] Error running login status:', error);
|
||||
logger.warn('Error running login status command during auth check:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CodexProvider.checkAuth] Not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { CodexUsageService } from '../../services/codex-usage-service.js';
|
||||
import { CodexModelCacheService } from '../../services/codex-model-cache-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Codex');
|
||||
|
||||
export function createCodexRoutes(service: CodexUsageService): Router {
|
||||
export function createCodexRoutes(
|
||||
usageService: CodexUsageService,
|
||||
modelCacheService: CodexModelCacheService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get current usage (attempts to fetch from Codex CLI)
|
||||
router.get('/usage', async (req: Request, res: Response) => {
|
||||
router.get('/usage', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if Codex CLI is available first
|
||||
const isAvailable = await service.isAvailable();
|
||||
const isAvailable = await usageService.isAvailable();
|
||||
if (!isAvailable) {
|
||||
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
|
||||
@@ -23,7 +27,7 @@ export function createCodexRoutes(service: CodexUsageService): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await service.fetchUsageData();
|
||||
const usage = await usageService.fetchUsageData();
|
||||
res.json(usage);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -52,5 +56,35 @@ export function createCodexRoutes(service: CodexUsageService): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Get available Codex models (cached)
|
||||
router.get('/models', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh);
|
||||
|
||||
if (models.length === 0) {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
error: 'Codex CLI not available or not authenticated',
|
||||
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
models,
|
||||
cachedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching models:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
212
apps/server/src/services/codex-app-server-service.ts
Normal file
212
apps/server/src/services/codex-app-server-service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import readline from 'readline';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
AppServerModelResponse,
|
||||
AppServerAccountResponse,
|
||||
AppServerRateLimitsResponse,
|
||||
JsonRpcRequest,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CodexAppServer');
|
||||
|
||||
/**
|
||||
* CodexAppServerService
|
||||
*
|
||||
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
|
||||
* Handles process spawning, JSON-RPC messaging, and cleanup.
|
||||
*
|
||||
* Connection strategy: Spawn on-demand (new process for each method call)
|
||||
*/
|
||||
export class CodexAppServerService {
|
||||
private cachedCliPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
this.cachedCliPath = await findCodexCliPath();
|
||||
return Boolean(this.cachedCliPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from app-server
|
||||
*/
|
||||
async getModels(): Promise<AppServerModelResponse | null> {
|
||||
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
|
||||
return sendRequest('model/list', {});
|
||||
});
|
||||
|
||||
if (result) {
|
||||
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch account information from app-server
|
||||
*/
|
||||
async getAccount(): Promise<AppServerAccountResponse | null> {
|
||||
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
|
||||
return sendRequest('account/read', { refreshToken: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch rate limits from app-server
|
||||
*/
|
||||
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
|
||||
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
|
||||
return sendRequest('account/rateLimits/read', {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JSON-RPC requests via Codex app-server
|
||||
*
|
||||
* This method:
|
||||
* 1. Spawns a new `codex app-server` process
|
||||
* 2. Handles JSON-RPC initialization handshake
|
||||
* 3. Executes user-provided requests
|
||||
* 4. Cleans up the process
|
||||
*
|
||||
* @param requestFn - Function that receives sendRequest helper and returns a promise
|
||||
* @returns Result of the JSON-RPC request or null on failure
|
||||
*/
|
||||
private async executeJsonRpc<T>(
|
||||
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
let childProcess: ChildProcess | null = null;
|
||||
|
||||
try {
|
||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||
|
||||
if (!cliPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 = <R>(method: string, params?: unknown): Promise<R> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++messageId;
|
||||
const request: JsonRpcRequest = {
|
||||
method,
|
||||
id,
|
||||
params: params ?? {},
|
||||
};
|
||||
|
||||
// Set timeout for request (10 seconds)
|
||||
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
|
||||
await sendRequest('initialize', {
|
||||
clientInfo: {
|
||||
name: 'automaker',
|
||||
title: 'AutoMaker',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Send initialized notification
|
||||
sendNotification('initialized');
|
||||
|
||||
// 3. Execute user-provided requests
|
||||
const result = await requestFn(sendRequest);
|
||||
|
||||
// Clean up
|
||||
rl.close();
|
||||
childProcess.kill('SIGTERM');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[executeJsonRpc] Failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// Ensure process is killed
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
258
apps/server/src/services/codex-model-cache-service.ts
Normal file
258
apps/server/src/services/codex-model-cache-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import path from 'path';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AppServerModel } from '@automaker/types';
|
||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||
|
||||
const logger = createLogger('CodexModelCache');
|
||||
|
||||
/**
|
||||
* Codex model with UI-compatible format
|
||||
*/
|
||||
export interface CodexModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache structure stored on disk
|
||||
*/
|
||||
interface CodexModelCache {
|
||||
models: CodexModel[];
|
||||
cachedAt: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexModelCacheService
|
||||
*
|
||||
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
|
||||
*
|
||||
* Features:
|
||||
* - 1-hour TTL (configurable)
|
||||
* - Atomic file writes (temp file + rename)
|
||||
* - Thread-safe (deduplicates concurrent refresh requests)
|
||||
* - Auto-bootstrap on service creation
|
||||
* - Graceful fallback (returns empty array on errors)
|
||||
*/
|
||||
export class CodexModelCacheService {
|
||||
private cacheFilePath: string;
|
||||
private ttl: number;
|
||||
private appServerService: CodexAppServerService;
|
||||
private inFlightRefresh: Promise<CodexModel[]> | null = null;
|
||||
|
||||
constructor(
|
||||
dataDir: string,
|
||||
appServerService: CodexAppServerService,
|
||||
ttl: number = 3600000 // 1 hour default
|
||||
) {
|
||||
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
|
||||
this.ttl = ttl;
|
||||
this.appServerService = appServerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models from cache or fetch if stale
|
||||
*
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Array of Codex models (empty array if unavailable)
|
||||
*/
|
||||
async getModels(forceRefresh = false): Promise<CodexModel[]> {
|
||||
// If force refresh, skip cache
|
||||
if (forceRefresh) {
|
||||
return this.refreshModels();
|
||||
}
|
||||
|
||||
// Try to load from cache
|
||||
const cached = await this.loadFromCache();
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.cachedAt;
|
||||
const isStale = age > cached.ttl;
|
||||
|
||||
if (!isStale) {
|
||||
logger.info(
|
||||
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
|
||||
);
|
||||
return cached.models;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is stale or missing, refresh
|
||||
return this.refreshModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models with cache metadata
|
||||
*
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Object containing models and cache timestamp
|
||||
*/
|
||||
async getModelsWithMetadata(
|
||||
forceRefresh = false
|
||||
): Promise<{ models: CodexModel[]; cachedAt: number }> {
|
||||
const models = await this.getModels(forceRefresh);
|
||||
|
||||
// Try to get the actual cache timestamp
|
||||
const cached = await this.loadFromCache();
|
||||
const cachedAt = cached?.cachedAt ?? Date.now();
|
||||
|
||||
return { models, cachedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from app-server and update cache
|
||||
*
|
||||
* Thread-safe: Deduplicates concurrent refresh requests
|
||||
*/
|
||||
async refreshModels(): Promise<CodexModel[]> {
|
||||
// Deduplicate concurrent refresh requests
|
||||
if (this.inFlightRefresh) {
|
||||
return this.inFlightRefresh;
|
||||
}
|
||||
|
||||
// Start new refresh
|
||||
this.inFlightRefresh = this.doRefresh();
|
||||
|
||||
try {
|
||||
const models = await this.inFlightRefresh;
|
||||
return models;
|
||||
} finally {
|
||||
this.inFlightRefresh = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache file
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
logger.info('[clearCache] Clearing cache...');
|
||||
|
||||
try {
|
||||
await secureFs.unlink(this.cacheFilePath);
|
||||
logger.info('[clearCache] Cache cleared');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('[clearCache] Failed to clear cache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform the actual refresh
|
||||
*/
|
||||
private async doRefresh(): Promise<CodexModel[]> {
|
||||
try {
|
||||
// Check if app-server is available
|
||||
const isAvailable = await this.appServerService.isAvailable();
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch models from app-server
|
||||
const response = await this.appServerService.getModels();
|
||||
if (!response || !response.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Transform models to UI format
|
||||
const models = response.data.map((model) => this.transformModel(model));
|
||||
|
||||
// Save to cache
|
||||
await this.saveToCache(models);
|
||||
|
||||
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
logger.error('[doRefresh] Refresh failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform app-server model to UI-compatible format
|
||||
*/
|
||||
private transformModel(appServerModel: AppServerModel): CodexModel {
|
||||
return {
|
||||
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
|
||||
label: appServerModel.displayName,
|
||||
description: appServerModel.description,
|
||||
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
|
||||
supportsVision: true, // All Codex models support vision
|
||||
tier: this.inferTier(appServerModel.id),
|
||||
isDefault: appServerModel.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer tier from model ID
|
||||
*/
|
||||
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
||||
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
||||
return 'premium';
|
||||
}
|
||||
if (modelId.includes('mini')) {
|
||||
return 'basic';
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache from disk
|
||||
*/
|
||||
private async loadFromCache(): Promise<CodexModelCache | null> {
|
||||
try {
|
||||
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
|
||||
const cache = JSON.parse(content.toString()) as CodexModelCache;
|
||||
|
||||
// Validate cache structure
|
||||
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
|
||||
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('[loadFromCache] Failed to read cache:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to disk (atomic write)
|
||||
*/
|
||||
private async saveToCache(models: CodexModel[]): Promise<void> {
|
||||
const cache: CodexModelCache = {
|
||||
models,
|
||||
cachedAt: Date.now(),
|
||||
ttl: this.ttl,
|
||||
};
|
||||
|
||||
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Write to temp file
|
||||
const content = JSON.stringify(cache, null, 2);
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
|
||||
// Atomic rename
|
||||
await secureFs.rename(tempPath, this.cacheFilePath);
|
||||
} catch (error) {
|
||||
logger.error('[saveToCache] Failed to save cache:', error);
|
||||
|
||||
// Clean up temp file
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
findCodexCliPath,
|
||||
spawnProcess,
|
||||
getCodexAuthPath,
|
||||
systemPathExists,
|
||||
systemPathReadFile,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||
|
||||
const logger = createLogger('CodexUsage');
|
||||
|
||||
@@ -18,19 +18,12 @@ export interface CodexRateLimitWindow {
|
||||
resetsAt: number;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSnapshot {
|
||||
balance?: string;
|
||||
unlimited?: boolean;
|
||||
hasCredits?: boolean;
|
||||
}
|
||||
|
||||
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
|
||||
|
||||
export interface CodexUsageData {
|
||||
rateLimits: {
|
||||
primary?: CodexRateLimitWindow;
|
||||
secondary?: CodexRateLimitWindow;
|
||||
credits?: CodexCreditsSnapshot;
|
||||
planType?: CodexPlanType;
|
||||
} | null;
|
||||
lastUpdated: string;
|
||||
@@ -39,13 +32,24 @@ export interface CodexUsageData {
|
||||
/**
|
||||
* Codex Usage Service
|
||||
*
|
||||
* Attempts to fetch usage data from Codex CLI and OpenAI API.
|
||||
* Codex CLI doesn't provide a direct usage command, but we can:
|
||||
* 1. Parse usage info from error responses (rate limit errors contain plan info)
|
||||
* 2. Check for OpenAI API usage if API key is available
|
||||
* 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
|
||||
@@ -58,60 +62,131 @@ export class CodexUsageService {
|
||||
/**
|
||||
* Attempt to fetch usage data
|
||||
*
|
||||
* Tries multiple approaches:
|
||||
* 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
|
||||
* 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');
|
||||
}
|
||||
|
||||
// Always try to get plan type from auth file first - this is the authoritative source
|
||||
const authPlanType = await this.getPlanTypeFromAuthFile();
|
||||
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
|
||||
|
||||
// Check if user has an API key that we can use
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
|
||||
if (hasApiKey) {
|
||||
// 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 app-server (most reliable method)
|
||||
const appServerUsage = await this.fetchFromAppServer();
|
||||
if (appServerUsage) {
|
||||
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
|
||||
return appServerUsage;
|
||||
}
|
||||
|
||||
// Try to get usage from Codex CLI by making a simple request
|
||||
const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
|
||||
if (codexUsage) {
|
||||
return codexUsage;
|
||||
}
|
||||
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
|
||||
|
||||
// Fallback: try to parse full usage from auth file
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If all else fails, return a message with helpful information
|
||||
throw new Error(
|
||||
'Codex usage statistics require additional configuration. ' +
|
||||
'To enable usage tracking:\n\n' +
|
||||
'1. Set your OpenAI API key in the environment:\n' +
|
||||
' export OPENAI_API_KEY=sk-...\n\n' +
|
||||
'2. Or check your usage at:\n' +
|
||||
' https://platform.openai.com/usage\n\n' +
|
||||
'Note: If using Codex CLI with ChatGPT OAuth authentication, ' +
|
||||
'usage data must be queried through your OpenAI account.'
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,9 +196,11 @@ export class CodexUsageService {
|
||||
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
||||
try {
|
||||
const authFilePath = getCodexAuthPath();
|
||||
const exists = await systemPathExists(authFilePath);
|
||||
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
|
||||
const exists = systemPathExists(authFilePath);
|
||||
|
||||
if (!exists) {
|
||||
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -131,16 +208,24 @@ export class CodexUsageService {
|
||||
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;
|
||||
@@ -188,154 +273,23 @@ export class CodexUsageService {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const normalizedType = accountType.toLowerCase() as CodexPlanType;
|
||||
logger.info(
|
||||
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
|
||||
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
|
||||
);
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
primary: {
|
||||
limit: 0,
|
||||
used: 0,
|
||||
remaining: 0,
|
||||
usedPercent: 100,
|
||||
windowDurationMins: Math.ceil(resetsInSeconds / 60),
|
||||
resetsAt,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
|
||||
return normalizedType;
|
||||
}
|
||||
} else {
|
||||
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,27 +297,27 @@ export class CodexUsageService {
|
||||
* 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 isFreePlan = planType === 'free';
|
||||
|
||||
return {
|
||||
const result: CodexUsageData = {
|
||||
rateLimits: {
|
||||
planType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isFreePlan,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse auth file:', error);
|
||||
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -372,7 +326,7 @@ export class CodexUsageService {
|
||||
/**
|
||||
* Parse JWT token to extract claims
|
||||
*/
|
||||
private parseJwt(token: string): any {
|
||||
private parseJwt(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
@@ -383,18 +337,8 @@ export class CodexUsageService {
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// 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('')
|
||||
);
|
||||
}
|
||||
// Use Buffer for Node.js environment
|
||||
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user