| 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'); |
|
|
| |
| |
| |
| export interface CodexModel { |
| id: string; |
| label: string; |
| description: string; |
| hasThinking: boolean; |
| supportsVision: boolean; |
| tier: 'premium' | 'standard' | 'basic'; |
| isDefault: boolean; |
| } |
|
|
| |
| |
| |
| interface CodexModelCache { |
| models: CodexModel[]; |
| cachedAt: number; |
| ttl: number; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class CodexModelCacheService { |
| private cacheFilePath: string; |
| private ttl: number; |
| private appServerService: CodexAppServerService; |
| private inFlightRefresh: Promise<CodexModel[]> | null = null; |
|
|
| constructor( |
| dataDir: string, |
| appServerService: CodexAppServerService, |
| ttl: number = 3600000 |
| ) { |
| this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json'); |
| this.ttl = ttl; |
| this.appServerService = appServerService; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async getModels(forceRefresh = false): Promise<CodexModel[]> { |
| |
| if (forceRefresh) { |
| return this.refreshModels(); |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| return this.refreshModels(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async getModelsWithMetadata( |
| forceRefresh = false |
| ): Promise<{ models: CodexModel[]; cachedAt: number }> { |
| const models = await this.getModels(forceRefresh); |
|
|
| |
| const cached = await this.loadFromCache(); |
| const cachedAt = cached?.cachedAt ?? Date.now(); |
|
|
| return { models, cachedAt }; |
| } |
|
|
| |
| |
| |
| |
| |
| async refreshModels(): Promise<CodexModel[]> { |
| |
| if (this.inFlightRefresh) { |
| return this.inFlightRefresh; |
| } |
|
|
| |
| this.inFlightRefresh = this.doRefresh(); |
|
|
| try { |
| const models = await this.inFlightRefresh; |
| return models; |
| } finally { |
| this.inFlightRefresh = null; |
| } |
| } |
|
|
| |
| |
| |
| async clearCache(): Promise<void> { |
| logger.info('[clearCache] Clearing cache...'); |
|
|
| try { |
| await secureFs.unlink(this.cacheFilePath); |
| logger.info('[clearCache] Cache cleared'); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| logger.error('[clearCache] Failed to clear cache:', error); |
| } |
| } |
| } |
|
|
| |
| |
| |
| private async doRefresh(): Promise<CodexModel[]> { |
| try { |
| |
| const isAvailable = await this.appServerService.isAvailable(); |
| if (!isAvailable) { |
| return []; |
| } |
|
|
| |
| const response = await this.appServerService.getModels(); |
| if (!response || !response.data) { |
| return []; |
| } |
|
|
| |
| const models = response.data.map((model) => this.transformModel(model)); |
|
|
| |
| await this.saveToCache(models); |
|
|
| logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`); |
|
|
| return models; |
| } catch (error) { |
| logger.error('[doRefresh] Refresh failed:', error); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| private transformModel(appServerModel: AppServerModel): CodexModel { |
| return { |
| id: `codex-${appServerModel.id}`, |
| label: appServerModel.displayName, |
| description: appServerModel.description, |
| hasThinking: appServerModel.supportedReasoningEfforts.length > 0, |
| supportsVision: true, |
| tier: this.inferTier(appServerModel.id), |
| isDefault: appServerModel.isDefault, |
| }; |
| } |
|
|
| |
| |
| |
| private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { |
| if ( |
| modelId.includes('max') || |
| modelId.includes('gpt-5.2-codex') || |
| modelId.includes('gpt-5.3-codex') |
| ) { |
| return 'premium'; |
| } |
| if (modelId.includes('mini')) { |
| return 'basic'; |
| } |
| return 'standard'; |
| } |
|
|
| |
| |
| |
| private async loadFromCache(): Promise<CodexModelCache | null> { |
| try { |
| const content = await secureFs.readFile(this.cacheFilePath, 'utf-8'); |
| const cache = JSON.parse(content.toString()) as CodexModelCache; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| private async saveToCache(models: CodexModel[]): Promise<void> { |
| const cache: CodexModelCache = { |
| models, |
| cachedAt: Date.now(), |
| ttl: this.ttl, |
| }; |
|
|
| const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`; |
|
|
| try { |
| |
| const content = JSON.stringify(cache, null, 2); |
| await secureFs.writeFile(tempPath, content, 'utf-8'); |
|
|
| |
| await secureFs.rename(tempPath, this.cacheFilePath); |
| } catch (error) { |
| logger.error('[saveToCache] Failed to save cache:', error); |
|
|
| |
| try { |
| await secureFs.unlink(tempPath); |
| } catch { |
| |
| } |
| } |
| } |
| } |
|
|