import { createHash, randomBytes } from 'node:crypto'; import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder'; import { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback'; import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel'; import { CodexAppServerSessionFactory, CodexBinaryResolver, JsonRpcRequestError, JsonRpcStdioClient, } from '@main/services/infrastructure/codexAppServer'; import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient'; import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CodexAccountFeatureFacade } from '@features/codex-account/main'; import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts'; import type { Logger } from '@shared/utils/logger'; type LoggerPort = Pick; const CATALOG_CACHE_TTL_MS = 10 * 60_000; const CATALOG_STALE_TTL_MS = 24 * 60 * 60_000; const HASH_SALT = randomBytes(16).toString('hex'); export interface CodexModelCatalogRequest { cwd?: string | null; profile?: string | null; includeHidden?: boolean; forceRefresh?: boolean; } export interface CodexModelCatalogFeatureFacade { getCatalog(options?: CodexModelCatalogRequest): Promise; invalidate(): void; dispose(): Promise; } function nowIso(): string { return new Date().toISOString(); } function staleAtIso(): string { return new Date(Date.now() + CATALOG_CACHE_TTL_MS).toISOString(); } function hashValue(value: unknown): string { return createHash('sha256') .update(HASH_SALT) .update(JSON.stringify(value ?? null)) .digest('hex') .slice(0, 16); } function classifyAppServerFailure(error: unknown): { appServerState: CodexModelCatalogDto['diagnostics']['appServerState']; message: string; code: string | null; } { const message = error instanceof Error ? error.message : String(error); const lower = message.toLowerCase(); const rpcCode = error instanceof JsonRpcRequestError && error.code !== null ? String(error.code) : null; if ( lower.includes('unknown method') || lower.includes('method not found') || lower.includes('unknown command') || lower.includes('no such command') || rpcCode === '-32601' ) { return { appServerState: 'incompatible', message: 'The installed Codex binary does not support app-server model/list yet.', code: rpcCode ?? 'method-not-found', }; } return { appServerState: 'degraded', message, code: rpcCode, }; } function createCacheKey(options: { binaryPath: string | null; binaryVersion: string | null; accountSnapshot: CodexAccountSnapshotDto; cwd?: string | null; profile?: string | null; configFingerprint?: string | null; includeHidden?: boolean; }): string { return hashValue({ binaryPath: options.binaryPath, binaryVersion: options.binaryVersion, preferredAuthMode: options.accountSnapshot.preferredAuthMode, effectiveAuthMode: options.accountSnapshot.effectiveAuthMode, managedAccount: options.accountSnapshot.managedAccount ? { type: options.accountSnapshot.managedAccount.type, planType: options.accountSnapshot.managedAccount.planType, emailHash: hashValue(options.accountSnapshot.managedAccount.email), } : null, apiKeySource: options.accountSnapshot.apiKey.source, cwd: options.cwd?.trim() || null, profile: options.profile?.trim() || null, configFingerprint: options.configFingerprint ?? null, includeHidden: options.includeHidden === true, codexHome: process.env.CODEX_HOME?.trim() || null, }); } function setCatalogCacheEntries( cache: InMemoryCodexModelCatalogCache, keys: readonly string[], catalog: CodexModelCatalogDto ): void { const seen = new Set(); for (const key of keys) { if (seen.has(key)) { continue; } seen.add(key); cache.set(key, catalog); } } function createFallbackCatalog(options: { sourceMessage: string; appServerState: CodexModelCatalogDto['diagnostics']['appServerState']; status?: CodexModelCatalogDto['status']; code?: string | null; }): CodexModelCatalogDto { const models = createStaticCodexModelCatalogModels(); const defaultModel = models.find((model) => model.isDefault) ?? models[0] ?? null; return { schemaVersion: 1, providerId: 'codex', source: 'static-fallback', status: options.status ?? 'degraded', fetchedAt: nowIso(), staleAt: staleAtIso(), defaultModelId: defaultModel?.id ?? null, defaultLaunchModel: defaultModel?.launchModel ?? null, models, diagnostics: { configReadState: 'skipped', appServerState: options.appServerState, message: options.sourceMessage, code: options.code ?? null, }, }; } function markCatalogStale( catalog: CodexModelCatalogDto, diagnostics: CodexModelCatalogDto['diagnostics'] ): CodexModelCatalogDto { return { ...catalog, status: 'stale', diagnostics, }; } export function createCodexModelCatalogFeature(options: { logger: LoggerPort; codexAccountFeature: Pick; }): CodexModelCatalogFeatureFacade { const envBuilder = new CodexAccountEnvBuilder(); const cache = new InMemoryCodexModelCatalogCache(); const inFlightRefreshes = new Map>(); let cacheGeneration = 0; const client = new CodexModelCatalogAppServerClient( new CodexAppServerSessionFactory(new JsonRpcStdioClient(options.logger)) ); async function getCatalog(request: CodexModelCatalogRequest = {}): Promise { const accountSnapshot = await options.codexAccountFeature.getSnapshot(); const binaryPath = await CodexBinaryResolver.resolve(); const binaryVersion = await CodexBinaryResolver.resolveVersion(binaryPath); if (!binaryPath) { return createFallbackCatalog({ sourceMessage: 'Codex CLI was not found. Showing static fallback model list.', appServerState: 'runtime-missing', status: 'unavailable', }); } const env = envBuilder.buildControlPlaneEnv({ binaryPath }); const preflightCacheKey = createCacheKey({ binaryPath, binaryVersion, accountSnapshot, cwd: request.cwd, profile: request.profile, configFingerprint: null, includeHidden: request.includeHidden, }); if (request.forceRefresh !== true) { const cached = cache.get(preflightCacheKey, CATALOG_CACHE_TTL_MS); if (cached) { return cached; } } const existingRefresh = inFlightRefreshes.get(preflightCacheKey); if (existingRefresh) { return existingRefresh; } const refreshGeneration = cacheGeneration; const refreshPromise = (async (): Promise => { let configFingerprint: string | null = null; let configReadState: CodexModelCatalogDto['diagnostics']['configReadState'] = 'skipped'; let configReadMessage: string | null = null; let cacheKey = preflightCacheKey; try { const payload = await client.readModelCatalogWithConfig({ binaryPath, env, includeHidden: request.includeHidden, cwd: request.cwd, profile: request.profile, }); if (payload.config.ok) { configReadState = 'ready'; configFingerprint = hashValue(payload.config.value); } else { configReadState = payload.config.error instanceof JsonRpcRequestError && payload.config.error.code === -32601 ? 'unsupported' : 'failed'; configReadMessage = payload.config.error instanceof Error ? payload.config.error.message : String(payload.config.error); } cacheKey = createCacheKey({ binaryPath, binaryVersion, accountSnapshot, cwd: request.cwd, profile: request.profile, configFingerprint, includeHidden: request.includeHidden, }); const normalized = normalizeCodexAppServerModels( payload.modelCatalog.models ?? payload.modelCatalog.data, { includeHidden: request.includeHidden, } ); const defaultModel = normalized.models.find((model) => model.id === normalized.defaultModelId) ?? normalized.models.find((model) => model.isDefault) ?? normalized.models[0] ?? null; const diagnostics = [ ...normalized.diagnostics, configReadMessage ? `config/read: ${configReadMessage}` : null, payload.modelCatalog.truncated ? 'model/list pagination reached the safety page limit; some Codex models may be omitted.' : null, ].filter(Boolean); const catalog: CodexModelCatalogDto = { schemaVersion: 1, providerId: 'codex', source: 'app-server', status: 'ready', fetchedAt: nowIso(), staleAt: staleAtIso(), defaultModelId: defaultModel?.id ?? null, defaultLaunchModel: defaultModel?.launchModel ?? null, models: normalized.models, diagnostics: { configReadState, appServerState: 'healthy', message: diagnostics.length > 0 ? diagnostics.join(' ') : null, code: null, }, }; if (normalized.models.length === 0) { throw new Error('Codex app-server model/list returned no visible models.'); } if (refreshGeneration === cacheGeneration) { setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], catalog); } return catalog; } catch (error) { const failure = classifyAppServerFailure(error); const stale = cache.getLatest(cacheKey) ?? (cacheKey === preflightCacheKey ? null : cache.getLatest(preflightCacheKey)); if (stale && Date.parse(stale.fetchedAt) + CATALOG_STALE_TTL_MS > Date.now()) { return markCatalogStale(stale, { configReadState, appServerState: failure.appServerState, message: failure.message, code: failure.code, }); } options.logger.warn('codex model catalog refresh failed', { error: failure.message, code: failure.code, }); const fallback = createFallbackCatalog({ sourceMessage: failure.message, appServerState: failure.appServerState, code: failure.code, }); if (refreshGeneration === cacheGeneration) { setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], fallback); } return fallback; } })(); inFlightRefreshes.set(preflightCacheKey, refreshPromise); try { return await refreshPromise; } finally { if (inFlightRefreshes.get(preflightCacheKey) === refreshPromise) { inFlightRefreshes.delete(preflightCacheKey); } } } return { getCatalog, invalidate: () => { cacheGeneration += 1; cache.clear(); inFlightRefreshes.clear(); }, dispose: async () => { cacheGeneration += 1; cache.clear(); inFlightRefreshes.clear(); }, }; }