From eb94e4de72a838954d22ae60b97f30cf680c5f35 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 00:11:42 +0100 Subject: [PATCH 1/5] 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. --- .../src/services/codex-usage-service.ts | 535 +++++++++++------- 1 file changed, 334 insertions(+), 201 deletions(-) diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index bf8aff99..fc3490b6 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -1,6 +1,7 @@ +import { spawn, type ChildProcess } from 'child_process'; +import readline from 'readline'; import { findCodexCliPath, - spawnProcess, getCodexAuthPath, systemPathExists, systemPathReadFile, @@ -36,17 +37,51 @@ export interface CodexUsageData { 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 * - * 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 accountPlanTypeArray: CodexPlanType[] = [ + 'free', + 'plus', + 'pro', + 'team', + 'enterprise', + 'edu', + ]; /** * Check if Codex CLI is available on the system */ @@ -58,60 +93,283 @@ 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 { + 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(cliPath); + if (appServerUsage) { + logger.info( + '[fetchUsageData] Got data from app-server:', + JSON.stringify(appServerUsage, null, 2) + ); + 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] Got data from auth file:', JSON.stringify(authUsage, null, 2)); 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', + credits: { + hasCredits: true, + }, + }, + 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 { + 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 = (method: string, params?: unknown): Promise => { + 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('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('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 { try { const authFilePath = getCodexAuthPath(); - const exists = await systemPathExists(authFilePath); + logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`); + const exists = systemPathExists(authFilePath); if (!exists) { + logger.info('[getPlanTypeFromAuthFile] Auth file does not exist'); return 'unknown'; } @@ -131,16 +391,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 +456,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 { - 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 { - 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,16 +480,19 @@ export class CodexUsageService { * Reuses getPlanTypeFromAuthFile to avoid code duplication */ private async fetchFromAuthFile(): Promise { + 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: { @@ -362,8 +502,11 @@ export class CodexUsageService { }, 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 +515,7 @@ export class CodexUsageService { /** * Parse JWT token to extract claims */ - private parseJwt(token: string): any { + private parseJwt(token: string): Record | null { try { const parts = token.split('.'); @@ -383,18 +526,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 { From 99b05d35a2a6a7d67ff4dc8db84fc5a41ab3a485 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 10 Jan 2026 14:33:55 +0100 Subject: [PATCH 2/5] feat: update Codex services and UI components for enhanced model management - Bumped version numbers for @automaker/server and @automaker/ui to 0.9.0 in package-lock.json. - Introduced CodexAppServerService and CodexModelCacheService to manage communication with the Codex CLI's app-server and cache model data. - Updated CodexUsageService to utilize app-server for fetching usage data. - Enhanced Codex routes to support fetching available models and integrated model caching. - Improved UI components to dynamically load and display Codex models, including error handling and loading states. - Added new API methods for fetching Codex models and integrated them into the app store for state management. These changes improve the overall functionality and user experience of the Codex integration, ensuring efficient model management and data retrieval. --- apps/server/src/index.ts | 13 +- apps/server/src/lib/codex-auth.ts | 50 +--- apps/server/src/providers/codex-provider.ts | 39 +-- apps/server/src/routes/codex/index.ts | 40 ++- .../src/services/codex-app-server-service.ts | 212 ++++++++++++++++ .../src/services/codex-model-cache-service.ts | 240 ++++++++++++++++++ .../src/services/codex-usage-service.ts | 221 ++-------------- .../board-view/shared/model-selector.tsx | 156 +++++++++--- .../model-defaults/phase-model-selector.tsx | 77 ++++-- apps/ui/src/lib/electron.ts | 14 + apps/ui/src/lib/http-api-client.ts | 19 ++ apps/ui/src/routes/__root.tsx | 17 +- apps/ui/src/store/app-store.ts | 80 ++++++ libs/types/src/codex-app-server.ts | 94 +++++++ libs/types/src/index.ts | 15 ++ package-lock.json | 103 +++----- 16 files changed, 981 insertions(+), 409 deletions(-) create mode 100644 apps/server/src/services/codex-app-server-service.ts create mode 100644 apps/server/src/services/codex-model-cache-service.ts create mode 100644 libs/types/src/codex-app-server.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 755569de..caa4dd6a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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)); diff --git a/apps/server/src/lib/codex-auth.ts b/apps/server/src/lib/codex-auth.ts index 965885bc..94fadc8c 100644 --- a/apps/server/src/lib/codex-auth.ts +++ b/apps/server/src/lib/codex-auth.ts @@ -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 { - 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' }; } diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 54e13989..e2df3de9 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -967,21 +967,11 @@ export class CodexProvider extends BaseProvider { } async detectInstallation(): Promise { - 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 +981,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 +998,6 @@ export class CodexProvider extends BaseProvider { hasApiKey, authenticated, }; - console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result)); - return result; } getAvailableModels(): ModelDefinition[] { @@ -1025,36 +1009,24 @@ export class CodexProvider extends BaseProvider { * Check authentication status for Codex CLI */ async checkAuth(): Promise { - 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 +1036,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); + // Silent fail } } - console.log('[CodexProvider.checkAuth] Not authenticated'); return { authenticated: false, method: 'none' }; } diff --git a/apps/server/src/routes/codex/index.ts b/apps/server/src/routes/codex/index.ts index 4a2db951..c9491a13 100644 --- a/apps/server/src/routes/codex/index.ts +++ b/apps/server/src/routes/codex/index.ts @@ -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) => { 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 = await modelCacheService.getModels(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: Date.now(), + }); + } 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; } diff --git a/apps/server/src/services/codex-app-server-service.ts b/apps/server/src/services/codex-app-server-service.ts new file mode 100644 index 00000000..ecfb99da --- /dev/null +++ b/apps/server/src/services/codex-app-server-service.ts @@ -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 { + this.cachedCliPath = await findCodexCliPath(); + return Boolean(this.cachedCliPath); + } + + /** + * Fetch available models from app-server + */ + async getModels(): Promise { + const result = await this.executeJsonRpc((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 { + return this.executeJsonRpc((sendRequest) => { + return sendRequest('account/read', { refreshToken: false }); + }); + } + + /** + * Fetch rate limits from app-server + */ + async getRateLimits(): Promise { + return this.executeJsonRpc((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( + requestFn: (sendRequest: (method: string, params?: unknown) => Promise) => Promise + ): Promise { + 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 = (method: string, params?: unknown): Promise => { + 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'); + } + } + } +} diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts new file mode 100644 index 00000000..ec65720f --- /dev/null +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -0,0 +1,240 @@ +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 | 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 { + // 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(); + } + + /** + * Refresh models from app-server and update cache + * + * Thread-safe: Deduplicates concurrent refresh requests + */ + async refreshModels(): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 + } + } + } +} diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index fc3490b6..e9420266 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -1,5 +1,3 @@ -import { spawn, type ChildProcess } from 'child_process'; -import readline from 'readline'; import { findCodexCliPath, getCodexAuthPath, @@ -7,6 +5,7 @@ import { systemPathReadFile, } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; +import type { CodexAppServerService } from './codex-app-server-service.js'; const logger = createLogger('CodexUsage'); @@ -37,35 +36,6 @@ export interface CodexUsageData { 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 * @@ -74,6 +44,7 @@ interface AppServerRateLimitsResponse { */ export class CodexUsageService { private cachedCliPath: string | null = null; + private appServerService: CodexAppServerService | null = null; private accountPlanTypeArray: CodexPlanType[] = [ 'free', 'plus', @@ -82,6 +53,11 @@ export class CodexUsageService { 'enterprise', 'edu', ]; + + constructor(appServerService?: CodexAppServerService) { + this.appServerService = appServerService || null; + } + /** * Check if Codex CLI is available on the system */ @@ -109,12 +85,9 @@ export class CodexUsageService { logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`); // Try to get usage from Codex app-server (most reliable method) - const appServerUsage = await this.fetchFromAppServer(cliPath); + const appServerUsage = await this.fetchFromAppServer(); if (appServerUsage) { - logger.info( - '[fetchUsageData] Got data from app-server:', - JSON.stringify(appServerUsage, null, 2) - ); + logger.info('[fetchUsageData] ✓ Fetched usage from app-server'); return appServerUsage; } @@ -123,7 +96,7 @@ export class CodexUsageService { // Fallback: try to parse usage from auth file const authUsage = await this.fetchFromAuthFile(); if (authUsage) { - logger.info('[fetchUsageData] Got data from auth file:', JSON.stringify(authUsage, null, 2)); + logger.info('[fetchUsageData] ✓ Fetched usage from auth file'); return authUsage; } @@ -145,139 +118,23 @@ export class CodexUsageService { * 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 { - let childProcess: ChildProcess | null = null; - + private async fetchFromAppServer(): Promise { 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'); + // Use CodexAppServerService if available + if (!this.appServerService) { + return null; } - // Setup readline for reading JSONL responses - const rl = readline.createInterface({ - input: childProcess.stdout, - crlfDelay: Infinity, - }); + // Fetch account and rate limits in parallel + const [accountResult, rateLimitsResult] = await Promise.all([ + this.appServerService.getAccount(), + this.appServerService.getRateLimits(), + ]); - // 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 = (method: string, params?: unknown): Promise => { - 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('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('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); + if (!accountResult) { + return null; } - // 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'; @@ -286,9 +143,6 @@ export class CodexUsageService { 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; } @@ -297,20 +151,11 @@ export class CodexUsageService { // 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, @@ -325,10 +170,6 @@ export class CodexUsageService { // 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, @@ -337,17 +178,11 @@ export class CodexUsageService { 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, @@ -358,17 +193,13 @@ export class CodexUsageService { }; } - logger.info('[fetchFromAppServer] Final result:', JSON.stringify(result, null, 2)); + logger.info( + `[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%` + ); return result; } catch (error) { - // App-server method failed, will fall back to other methods - logger.error('Failed to fetch from app-server:', error); + logger.error('[fetchFromAppServer] Failed:', error); return null; - } finally { - // Ensure process is killed - if (childProcess && !childProcess.killed) { - childProcess.kill('SIGTERM'); - } } } @@ -383,7 +214,7 @@ export class CodexUsageService { const exists = systemPathExists(authFilePath); if (!exists) { - logger.info('[getPlanTypeFromAuthFile] Auth file does not exist'); + logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist'); return 'unknown'; } diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 65d29dca..323190c8 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -9,7 +9,9 @@ import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; import type { ModelProvider } from '@automaker/types'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants'; +import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants'; +import { useEffect } from 'react'; +import { RefreshCw } from 'lucide-react'; interface ModelSelectorProps { selectedModel: string; // Can be ModelAlias or "cursor-{id}" @@ -22,7 +24,14 @@ export function ModelSelector({ onModelSelect, testIdPrefix = 'model-select', }: ModelSelectorProps) { - const { enabledCursorModels, cursorDefaultModel } = useAppStore(); + const { + enabledCursorModels, + cursorDefaultModel, + codexModels, + codexModelsLoading, + codexModelsError, + fetchCodexModels, + } = useAppStore(); const { cursorCliStatus, codexCliStatus } = useSetupStore(); const selectedProvider = getModelProvider(selectedModel); @@ -33,6 +42,31 @@ export function ModelSelector({ // Check if Codex CLI is available const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; + // Fetch Codex models on mount + useEffect(() => { + if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) { + fetchCodexModels(); + } + }, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]); + + // Transform codex models from store to ModelOption format + const dynamicCodexModels: ModelOption[] = codexModels.map((model) => { + // Infer badge based on tier + let badge: string | undefined; + if (model.tier === 'premium') badge = 'Premium'; + else if (model.tier === 'basic') badge = 'Speed'; + else if (model.tier === 'standard') badge = 'Balanced'; + + return { + id: model.id, + label: model.label, + description: model.description, + badge, + provider: 'codex' as ModelProvider, + hasThinking: model.hasThinking, + }; + }); + // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { // Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto") @@ -45,8 +79,10 @@ export function ModelSelector({ // Switch to Cursor's default model (from global settings) onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`); } else if (provider === 'codex' && selectedProvider !== 'codex') { - // Switch to Codex's default model (codex-gpt-5.2-codex) - onModelSelect('codex-gpt-5.2-codex'); + // Switch to Codex's default model (use isDefault flag from dynamic models) + const defaultModel = codexModels.find((m) => m.isDefault); + const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex'; + onModelSelect(defaultModelId); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model onModelSelect('sonnet'); @@ -234,41 +270,91 @@ export function ModelSelector({ CLI -
- {CODEX_MODELS.map((option) => { - const isSelected = selectedModel === option.id; - return ( + + {/* Loading state */} + {codexModelsLoading && dynamicCodexModels.length === 0 && ( +
+ + Loading models... +
+ )} + + {/* Error state */} + {codexModelsError && !codexModelsLoading && ( +
+ +
+
Failed to load Codex models
- ); - })} -
+
+
+ )} + + {/* Model list */} + {!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && ( +
+ No Codex models available +
+ )} + + {!codexModelsLoading && dynamicCodexModels.length > 0 && ( +
+ {dynamicCodexModels.map((option) => { + const isSelected = selectedModel === option.id; + return ( + + ); + })} +
+ )} )} diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index c64b5bd4..28d2ed1d 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import type { @@ -8,8 +8,6 @@ import type { OpencodeModelId, GroupedModel, PhaseModelEntry, - ThinkingLevel, - ReasoningEffort, } from '@automaker/types'; import { stripProviderPrefix, @@ -17,13 +15,11 @@ import { getModelGroup, isGroupSelected, getSelectedVariant, - isCursorModel, codexModelHasThinking, } from '@automaker/types'; import { CLAUDE_MODELS, CURSOR_MODELS, - CODEX_MODELS, OPENCODE_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, @@ -73,23 +69,39 @@ export function PhaseModelSelector({ align = 'end', disabled = false, }: PhaseModelSelectorProps) { - const [open, setOpen] = React.useState(false); - const [expandedGroup, setExpandedGroup] = React.useState(null); - const [expandedClaudeModel, setExpandedClaudeModel] = React.useState(null); - const [expandedCodexModel, setExpandedCodexModel] = React.useState(null); - const commandListRef = React.useRef(null); - const expandedTriggerRef = React.useRef(null); - const expandedClaudeTriggerRef = React.useRef(null); - const expandedCodexTriggerRef = React.useRef(null); - const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore(); + const [open, setOpen] = useState(false); + const [expandedGroup, setExpandedGroup] = useState(null); + const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); + const [expandedCodexModel, setExpandedCodexModel] = useState(null); + const commandListRef = useRef(null); + const expandedTriggerRef = useRef(null); + const expandedClaudeTriggerRef = useRef(null); + const expandedCodexTriggerRef = useRef(null); + const { + enabledCursorModels, + favoriteModels, + toggleFavoriteModel, + codexModels, + codexModelsLoading, + fetchCodexModels, + } = useAppStore(); // Extract model and thinking/reasoning levels from value const selectedModel = value.model; const selectedThinkingLevel = value.thinkingLevel || 'none'; const selectedReasoningEffort = value.reasoningEffort || 'none'; + // Fetch Codex models on mount + useEffect(() => { + if (codexModels.length === 0 && !codexModelsLoading) { + fetchCodexModels().catch(() => { + // Silently fail - user will see empty Codex section + }); + } + }, [codexModels.length, codexModelsLoading, fetchCodexModels]); + // Close expanded group when trigger scrolls out of view - React.useEffect(() => { + useEffect(() => { const triggerElement = expandedTriggerRef.current; const listElement = commandListRef.current; if (!triggerElement || !listElement || !expandedGroup) return; @@ -112,7 +124,7 @@ export function PhaseModelSelector({ }, [expandedGroup]); // Close expanded Claude model popover when trigger scrolls out of view - React.useEffect(() => { + useEffect(() => { const triggerElement = expandedClaudeTriggerRef.current; const listElement = commandListRef.current; if (!triggerElement || !listElement || !expandedClaudeModel) return; @@ -135,7 +147,7 @@ export function PhaseModelSelector({ }, [expandedClaudeModel]); // Close expanded Codex model popover when trigger scrolls out of view - React.useEffect(() => { + useEffect(() => { const triggerElement = expandedCodexTriggerRef.current; const listElement = commandListRef.current; if (!triggerElement || !listElement || !expandedCodexModel) return; @@ -157,6 +169,17 @@ export function PhaseModelSelector({ return () => observer.disconnect(); }, [expandedCodexModel]); + // Transform dynamic Codex models from store to component format + const transformedCodexModels = useMemo(() => { + return codexModels.map((model) => ({ + id: model.id, + label: model.label, + description: model.description, + provider: 'codex' as const, + badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined, + })); + }, [codexModels]); + // Filter Cursor models to only show enabled ones const availableCursorModels = CURSOR_MODELS.filter((model) => { const cursorId = stripProviderPrefix(model.id) as CursorModelId; @@ -164,7 +187,7 @@ export function PhaseModelSelector({ }); // Helper to find current selected model details - const currentModel = React.useMemo(() => { + const currentModel = useMemo(() => { const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); if (claudeModel) { // Add thinking level to label if not 'none' @@ -198,7 +221,7 @@ export function PhaseModelSelector({ } // Check Codex models - const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel); + const codexModel = transformedCodexModels.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; // Check OpenCode models @@ -206,10 +229,10 @@ export function PhaseModelSelector({ if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon }; return null; - }, [selectedModel, selectedThinkingLevel, availableCursorModels]); + }, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]); // Compute grouped vs standalone Cursor models - const { groupedModels, standaloneCursorModels } = React.useMemo(() => { + const { groupedModels, standaloneCursorModels } = useMemo(() => { const grouped: GroupedModel[] = []; const standalone: typeof CURSOR_MODELS = []; const seenGroups = new Set(); @@ -242,11 +265,11 @@ export function PhaseModelSelector({ }, [availableCursorModels, enabledCursorModels]); // Group models - const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => { + const { favorites, claude, cursor, codex, opencode } = useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; - const codModels: typeof CODEX_MODELS = []; + const codModels: typeof transformedCodexModels = []; const ocModels: typeof OPENCODE_MODELS = []; // Process Claude Models @@ -268,7 +291,7 @@ export function PhaseModelSelector({ }); // Process Codex Models - CODEX_MODELS.forEach((model) => { + transformedCodexModels.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { @@ -292,10 +315,10 @@ export function PhaseModelSelector({ codex: codModels, opencode: ocModels, }; - }, [favoriteModels, availableCursorModels]); + }, [favoriteModels, availableCursorModels, transformedCodexModels]); // Render Codex model item with secondary popover for reasoning effort (only for models that support it) - const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => { + const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => { const isSelected = selectedModel === model.id; const isFavorite = favoriteModels.includes(model.id); const hasReasoning = codexModelHasThinking(model.id as CodexModelId); @@ -919,7 +942,7 @@ export function PhaseModelSelector({ } // Codex model if (model.provider === 'codex') { - return renderCodexModelItem(model); + return renderCodexModelItem(model as (typeof transformedCodexModels)[0]); } // OpenCode model if (model.provider === 'opencode') { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 84e0d945..9a746a6a 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -727,6 +727,20 @@ export interface ElectronAPI { ideation?: IdeationAPI; codex?: { getUsage: () => Promise; + getModels: (refresh?: boolean) => Promise<{ + success: boolean; + models?: Array<{ + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; + }>; + cachedAt?: number; + error?: string; + }>; }; settings?: { getStatus: () => Promise<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 691dcfec..effe8207 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2056,6 +2056,25 @@ export class HttpApiClient implements ElectronAPI { // Codex API codex = { getUsage: (): Promise => this.get('/api/codex/usage'), + getModels: ( + refresh = false + ): Promise<{ + success: boolean; + models?: Array<{ + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; + }>; + cachedAt?: number; + error?: string; + }> => { + const url = `/api/codex/models${refresh ? '?refresh=true' : ''}`; + return this.get(url); + }, }; // Context API diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 1e940dbf..6a883071 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -68,8 +68,9 @@ function RootLayoutContent() { getEffectiveTheme, skipSandboxWarning, setSkipSandboxWarning, + fetchCodexModels, } = useAppStore(); - const { setupComplete } = useSetupStore(); + const { setupComplete, codexCliStatus } = useSetupStore(); const navigate = useNavigate(); const [isMounted, setIsMounted] = useState(false); const [streamerPanelOpen, setStreamerPanelOpen] = useState(false); @@ -431,6 +432,20 @@ function RootLayoutContent() { } }, [isMounted, currentProject, location.pathname, navigate]); + // Bootstrap Codex models on app startup (after auth completes) + useEffect(() => { + // Only fetch if authenticated and Codex CLI is available + if (!authChecked || !isAuthenticated) return; + + const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; + if (!isCodexAvailable) return; + + // Fetch models in the background + fetchCodexModels().catch((error) => { + logger.warn('Failed to bootstrap Codex models:', error); + }); + }, [authChecked, isAuthenticated, codexCliStatus, fetchCodexModels]); + // Apply theme class to document - use deferred value to avoid blocking UI useEffect(() => { const root = document.documentElement; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 78d6e65c..b6de532b 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; // Note: persist middleware removed - settings now sync via API (use-settings-sync.ts) import type { Project, TrashedProject } from '@/lib/electron'; +import { getElectronAPI } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import { setItem, getItem } from '@/lib/storage'; import type { @@ -652,6 +653,20 @@ export interface AppState { codexUsage: CodexUsage | null; codexUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) + codexModels: Array<{ + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; + }>; + codexModelsLoading: boolean; + codexModelsError: string | null; + codexModelsLastFetched: number | null; + // Pipeline Configuration (per-project, keyed by project path) pipelineConfigByProject: Record; @@ -1093,6 +1108,20 @@ export interface AppActions { // Codex Usage Tracking actions setCodexUsage: (usage: CodexUsage | null) => void; + // Codex Models actions + fetchCodexModels: (forceRefresh?: boolean) => Promise; + setCodexModels: ( + models: Array<{ + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; + }> + ) => void; + // Reset reset: () => void; } @@ -1233,6 +1262,10 @@ const initialState: AppState = { claudeUsageLastUpdated: null, codexUsage: null, codexUsageLastUpdated: null, + codexModels: [], + codexModelsLoading: false, + codexModelsError: null, + codexModelsLastFetched: null, pipelineConfigByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, @@ -3016,6 +3049,53 @@ export const useAppStore = create()((set, get) => ({ codexUsageLastUpdated: usage ? Date.now() : null, }), + // Codex Models actions + fetchCodexModels: async (forceRefresh = false) => { + const { codexModelsLastFetched, codexModelsLoading } = get(); + + // Skip if already loading + if (codexModelsLoading) return; + + // Skip if recently fetched (< 5 minutes ago) and not forcing refresh + if (!forceRefresh && codexModelsLastFetched && Date.now() - codexModelsLastFetched < 300000) { + return; + } + + set({ codexModelsLoading: true, codexModelsError: null }); + + try { + const api = getElectronAPI(); + if (!api.codex) { + throw new Error('Codex API not available'); + } + + const result = await api.codex.getModels(forceRefresh); + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Codex models'); + } + + set({ + codexModels: result.models || [], + codexModelsLastFetched: Date.now(), + codexModelsLoading: false, + codexModelsError: null, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + set({ + codexModelsError: errorMessage, + codexModelsLoading: false, + }); + } + }, + + setCodexModels: (models) => + set({ + codexModels: models, + codexModelsLastFetched: Date.now(), + }), + // Pipeline actions setPipelineConfig: (projectPath, config) => { set({ diff --git a/libs/types/src/codex-app-server.ts b/libs/types/src/codex-app-server.ts new file mode 100644 index 00000000..f255eac9 --- /dev/null +++ b/libs/types/src/codex-app-server.ts @@ -0,0 +1,94 @@ +/** + * Codex App-Server JSON-RPC Types + * + * Type definitions for communicating with Codex CLI's app-server via JSON-RPC protocol. + * These types match the response structures from the `codex app-server` command. + */ + +/** + * Response from model/list JSON-RPC method + * Returns list of available Codex models for the authenticated user + */ +export interface AppServerModelResponse { + data: AppServerModel[]; + nextCursor: string | null; +} + +export interface AppServerModel { + id: string; + model: string; + displayName: string; + description: string; + supportedReasoningEfforts: AppServerReasoningEffort[]; + defaultReasoningEffort: string; + isDefault: boolean; +} + +export interface AppServerReasoningEffort { + reasoningEffort: string; + description: string; +} + +/** + * Response from account/read JSON-RPC method + * Returns current authentication state and account information + */ +export interface AppServerAccountResponse { + account: AppServerAccount | null; + requiresOpenaiAuth: boolean; +} + +export interface AppServerAccount { + type: 'apiKey' | 'chatgpt'; + email?: string; + planType?: string; +} + +/** + * Response from account/rateLimits/read JSON-RPC method + * Returns rate limit information for the current user + */ +export interface AppServerRateLimitsResponse { + rateLimits: AppServerRateLimits; +} + +export interface AppServerRateLimits { + primary: AppServerRateLimitWindow | null; + secondary: AppServerRateLimitWindow | null; + credits?: AppServerCredits; + planType?: string; +} + +export interface AppServerRateLimitWindow { + usedPercent: number; + windowDurationMins: number; + resetsAt: number; +} + +export interface AppServerCredits { + hasCredits: boolean; + unlimited: boolean; + balance: string; +} + +/** + * Generic JSON-RPC request structure + */ +export interface JsonRpcRequest { + method: string; + id: number; + params?: unknown; +} + +/** + * Generic JSON-RPC response structure + */ +export interface JsonRpcResponse { + id: number; + result?: T; + error?: { + code: number; + message: string; + data?: unknown; + }; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 259e251d..a28cfc69 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -30,6 +30,21 @@ export type { } from './codex.js'; export * from './codex-models.js'; +// Codex App-Server JSON-RPC types +export type { + AppServerModelResponse, + AppServerModel, + AppServerReasoningEffort, + AppServerAccountResponse, + AppServerAccount, + AppServerRateLimitsResponse, + AppServerRateLimits, + AppServerRateLimitWindow, + AppServerCredits, + JsonRpcRequest, + JsonRpcResponse, +} from './codex-app-server.js'; + // Feature types export type { Feature, diff --git a/package-lock.json b/package-lock.json index 6481a7fb..2079b66d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.8.0", + "version": "0.9.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -80,7 +80,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.8.0", + "version": "0.9.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -677,6 +677,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1260,6 +1261,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1302,6 +1304,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2122,7 +2125,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -2144,7 +2146,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2161,7 +2162,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -2176,7 +2176,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2944,7 +2943,6 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -3069,7 +3067,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -3086,7 +3083,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -3103,7 +3099,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -3212,7 +3207,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3235,7 +3229,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3258,7 +3251,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3344,7 +3336,6 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -3367,7 +3358,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3387,7 +3377,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3787,8 +3776,7 @@ "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { "version": "16.0.10", @@ -3802,7 +3790,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3819,7 +3806,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3836,7 +3822,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3853,7 +3838,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3870,7 +3854,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3887,7 +3870,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3904,7 +3886,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3921,7 +3902,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -4021,6 +4001,7 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -5461,7 +5442,6 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -5795,6 +5775,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -6221,6 +6202,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -6363,6 +6345,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6373,6 +6356,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6478,6 +6462,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -6971,7 +6956,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -7069,6 +7055,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7129,6 +7116,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7727,6 +7715,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8258,8 +8247,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cliui": { "version": "8.0.1", @@ -8564,8 +8552,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8662,6 +8649,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8963,6 +8951,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -9289,7 +9278,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9310,7 +9298,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9561,6 +9548,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9875,6 +9863,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11542,7 +11531,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11564,7 +11552,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11586,7 +11573,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11608,7 +11594,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11630,7 +11615,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11652,7 +11636,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11674,7 +11657,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11696,7 +11678,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11718,7 +11699,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11740,7 +11720,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11762,7 +11741,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -14050,7 +14028,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -14067,7 +14044,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -14085,7 +14061,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14274,6 +14249,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14283,6 +14259,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14641,7 +14618,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14830,6 +14806,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -14878,7 +14855,6 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14929,7 +14905,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14952,7 +14927,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14975,7 +14949,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14992,7 +14965,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -15009,7 +14981,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -15026,7 +14997,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -15043,7 +15013,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -15060,7 +15029,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -15077,7 +15045,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -15094,7 +15061,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15117,7 +15083,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15140,7 +15105,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15163,7 +15127,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15186,7 +15149,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15209,7 +15171,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15678,7 +15639,6 @@ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", - "peer": true, "dependencies": { "client-only": "0.0.1" }, @@ -15848,7 +15808,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15912,7 +15871,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16010,6 +15968,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16214,6 +16173,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16585,6 +16545,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16674,7 +16635,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -16700,6 +16662,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16742,6 +16705,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -17067,6 +17031,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From c5009a03331d4803ac6cfe7917b2d2764932ea43 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 10 Jan 2026 14:43:57 +0100 Subject: [PATCH 3/5] refactor: remove Codex credits handling from services and UI components - Eliminated CodexCreditsSnapshot interface and related logic from CodexUsageService and UI components. - Updated CodexUsageSection to display only plan type, removing credits information for a cleaner interface. - Streamlined Codex usage formatting functions by removing unused credit formatting logic. These changes simplify the Codex usage management by focusing on plan types, enhancing clarity and maintainability. --- .../src/services/codex-usage-service.ts | 20 ------------------- .../codex/codex-usage-section.tsx | 20 ++++--------------- apps/ui/src/lib/codex-usage-format.ts | 13 +----------- apps/ui/src/store/app-store.ts | 7 ------- libs/types/src/codex-app-server.ts | 7 ------- libs/types/src/index.ts | 1 - 6 files changed, 5 insertions(+), 63 deletions(-) diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index e9420266..c10e8df3 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -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; @@ -106,9 +99,6 @@ export class CodexUsageService { return { rateLimits: { planType: 'unknown', - credits: { - hasCredits: true, - }, }, lastUpdated: new Date().toISOString(), }; @@ -159,10 +149,6 @@ export class CodexUsageService { const result: CodexUsageData = { rateLimits: { planType, - credits: { - hasCredits: true, - unlimited: planType !== 'free' && planType !== 'unknown', - }, }, lastUpdated: new Date().toISOString(), }; @@ -321,15 +307,9 @@ export class CodexUsageService { return null; } - const isFreePlan = planType === 'free'; - const result: CodexUsageData = { rateLimits: { planType, - credits: { - hasCredits: true, - unlimited: !isFreePlan, - }, }, lastUpdated: new Date().toISOString(), }; diff --git a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx index 1e927777..b879df4a 100644 --- a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx @@ -6,7 +6,6 @@ import { OpenAIIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; import { - formatCodexCredits, formatCodexPlanType, formatCodexResetTime, getCodexWindowLabel, @@ -25,7 +24,6 @@ const UPDATED_LABEL = 'Updated'; const CODEX_FETCH_ERROR = 'Failed to fetch usage'; const CODEX_REFRESH_LABEL = 'Refresh Codex usage'; const PLAN_LABEL = 'Plan'; -const CREDITS_LABEL = 'Credits'; const WARNING_THRESHOLD = 75; const CAUTION_THRESHOLD = 50; const MAX_PERCENTAGE = 100; @@ -49,7 +47,6 @@ export function CodexUsageSection() { const rateLimits = codexUsage?.rateLimits ?? null; const primary = rateLimits?.primary ?? null; const secondary = rateLimits?.secondary ?? null; - const credits = rateLimits?.credits ?? null; const planType = rateLimits?.planType ?? null; const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow); const hasMetrics = rateLimitWindows.length > 0; @@ -206,20 +203,11 @@ export function CodexUsageSection() { })} )} - {(planType || credits) && ( + {planType && (
- {planType && ( -
- {PLAN_LABEL}:{' '} - {formatCodexPlanType(planType)} -
- )} - {credits && ( -
- {CREDITS_LABEL}:{' '} - {formatCodexCredits(credits)} -
- )} +
+ {PLAN_LABEL}: {formatCodexPlanType(planType)} +
)} {!hasMetrics && !error && canFetchUsage && !isLoading && ( diff --git a/apps/ui/src/lib/codex-usage-format.ts b/apps/ui/src/lib/codex-usage-format.ts index 288898b2..25114a57 100644 --- a/apps/ui/src/lib/codex-usage-format.ts +++ b/apps/ui/src/lib/codex-usage-format.ts @@ -1,12 +1,8 @@ -import { type CodexCreditsSnapshot, type CodexPlanType } from '@/store/app-store'; +import { type CodexPlanType } from '@/store/app-store'; const WINDOW_DEFAULT_LABEL = 'Usage window'; const RESET_LABEL = 'Resets'; const UNKNOWN_LABEL = 'Unknown'; -const UNAVAILABLE_LABEL = 'Unavailable'; -const UNLIMITED_LABEL = 'Unlimited'; -const AVAILABLE_LABEL = 'Available'; -const NONE_LABEL = 'None'; const DAY_UNIT = 'day'; const HOUR_UNIT = 'hour'; const MINUTE_UNIT = 'min'; @@ -77,10 +73,3 @@ export function formatCodexPlanType(plan: CodexPlanType | null): string { if (!plan) return UNKNOWN_LABEL; return PLAN_TYPE_LABELS[plan] ?? plan; } - -export function formatCodexCredits(snapshot: CodexCreditsSnapshot | null): string { - if (!snapshot) return UNAVAILABLE_LABEL; - if (snapshot.unlimited) return UNLIMITED_LABEL; - if (snapshot.balance) return snapshot.balance; - return snapshot.hasCredits ? AVAILABLE_LABEL : NONE_LABEL; -} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index b6de532b..2a2b1224 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -719,12 +719,6 @@ export type CodexPlanType = | 'edu' | 'unknown'; -export interface CodexCreditsSnapshot { - balance?: string; - unlimited?: boolean; - hasCredits?: boolean; -} - export interface CodexRateLimitWindow { limit: number; used: number; @@ -738,7 +732,6 @@ export interface CodexUsage { rateLimits: { primary?: CodexRateLimitWindow; secondary?: CodexRateLimitWindow; - credits?: CodexCreditsSnapshot; planType?: CodexPlanType; } | null; lastUpdated: string; diff --git a/libs/types/src/codex-app-server.ts b/libs/types/src/codex-app-server.ts index f255eac9..454a77b2 100644 --- a/libs/types/src/codex-app-server.ts +++ b/libs/types/src/codex-app-server.ts @@ -55,7 +55,6 @@ export interface AppServerRateLimitsResponse { export interface AppServerRateLimits { primary: AppServerRateLimitWindow | null; secondary: AppServerRateLimitWindow | null; - credits?: AppServerCredits; planType?: string; } @@ -65,12 +64,6 @@ export interface AppServerRateLimitWindow { resetsAt: number; } -export interface AppServerCredits { - hasCredits: boolean; - unlimited: boolean; - balance: string; -} - /** * Generic JSON-RPC request structure */ diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a28cfc69..f02df113 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -40,7 +40,6 @@ export type { AppServerRateLimitsResponse, AppServerRateLimits, AppServerRateLimitWindow, - AppServerCredits, JsonRpcRequest, JsonRpcResponse, } from './codex-app-server.js'; From 604f98b08f5dca2149867bebe094fc593bae232f Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 10 Jan 2026 16:16:44 +0100 Subject: [PATCH 4/5] chore: ignore .codex/config.toml to prevent API key exposure Move .codex/config.toml to .gitignore to prevent accidental commits of API keys. The file will remain local to each user's setup. Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7d02e8ba..91571307 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ blob-report/ !.env.example !.env.local.example +# Codex config (contains API keys) +.codex/config.toml + # TypeScript *.tsbuildinfo From c99883e634c07ef12db0b4210bfc34643e99191a Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 10 Jan 2026 16:26:12 +0100 Subject: [PATCH 5/5] fix: address PR review comments - 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 --- apps/server/src/providers/codex-provider.ts | 5 ++++- apps/server/src/routes/codex/index.ts | 6 +++--- .../src/services/codex-model-cache-service.ts | 18 ++++++++++++++++++ .../server/src/services/codex-usage-service.ts | 12 ++++++------ 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index e2df3de9..2e3962a0 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -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 { + router.get('/usage', async (_req: Request, res: Response) => { try { // Check if Codex CLI is available first const isAvailable = await usageService.isAvailable(); @@ -60,7 +60,7 @@ export function createCodexRoutes( router.get('/models', async (req: Request, res: Response) => { try { const forceRefresh = req.query.refresh === 'true'; - const models = await modelCacheService.getModels(forceRefresh); + const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh); if (models.length === 0) { res.status(503).json({ @@ -74,7 +74,7 @@ export function createCodexRoutes( res.json({ success: true, models, - cachedAt: Date.now(), + cachedAt, }); } catch (error) { logger.error('Error fetching models:', error); diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts index ec65720f..7e171428 100644 --- a/apps/server/src/services/codex-model-cache-service.ts +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -86,6 +86,24 @@ export class CodexModelCacheService { 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 * diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index c10e8df3..e18d508e 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -157,9 +157,9 @@ export class CodexUsageService { if (rateLimitsResult?.rateLimits?.primary) { const primary = rateLimitsResult.rateLimits.primary; result.rateLimits!.primary = { - limit: 100, // Not provided by API, using placeholder - used: primary.usedPercent, - remaining: 100 - primary.usedPercent, + 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, @@ -170,9 +170,9 @@ export class CodexUsageService { if (rateLimitsResult?.rateLimits?.secondary) { const secondary = rateLimitsResult.rateLimits.secondary; result.rateLimits!.secondary = { - limit: 100, - used: secondary.usedPercent, - remaining: 100 - secondary.usedPercent, + 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,