File size: 7,103 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import path from 'path';
import { secureFs } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type { AppServerModel } from '@automaker/types';
import type { CodexAppServerService } from './codex-app-server-service.js';

const logger = createLogger('CodexModelCache');

/**
 * Codex model with UI-compatible format
 */
export interface CodexModel {
  id: string;
  label: string;
  description: string;
  hasThinking: boolean;
  supportsVision: boolean;
  tier: 'premium' | 'standard' | 'basic';
  isDefault: boolean;
}

/**
 * Cache structure stored on disk
 */
interface CodexModelCache {
  models: CodexModel[];
  cachedAt: number;
  ttl: number;
}

/**
 * CodexModelCacheService
 *
 * Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
 *
 * Features:
 * - 1-hour TTL (configurable)
 * - Atomic file writes (temp file + rename)
 * - Thread-safe (deduplicates concurrent refresh requests)
 * - Auto-bootstrap on service creation
 * - Graceful fallback (returns empty array on errors)
 */
export class CodexModelCacheService {
  private cacheFilePath: string;
  private ttl: number;
  private appServerService: CodexAppServerService;
  private inFlightRefresh: Promise<CodexModel[]> | null = null;

  constructor(
    dataDir: string,
    appServerService: CodexAppServerService,
    ttl: number = 3600000 // 1 hour default
  ) {
    this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
    this.ttl = ttl;
    this.appServerService = appServerService;
  }

  /**
   * Get models from cache or fetch if stale
   *
   * @param forceRefresh - If true, bypass cache and fetch fresh data
   * @returns Array of Codex models (empty array if unavailable)
   */
  async getModels(forceRefresh = false): Promise<CodexModel[]> {
    // If force refresh, skip cache
    if (forceRefresh) {
      return this.refreshModels();
    }

    // Try to load from cache
    const cached = await this.loadFromCache();
    if (cached) {
      const age = Date.now() - cached.cachedAt;
      const isStale = age > cached.ttl;

      if (!isStale) {
        logger.info(
          `[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
        );
        return cached.models;
      }
    }

    // Cache is stale or missing, refresh
    return this.refreshModels();
  }

  /**
   * Get models with cache metadata
   *
   * @param forceRefresh - If true, bypass cache and fetch fresh data
   * @returns Object containing models and cache timestamp
   */
  async getModelsWithMetadata(
    forceRefresh = false
  ): Promise<{ models: CodexModel[]; cachedAt: number }> {
    const models = await this.getModels(forceRefresh);

    // Try to get the actual cache timestamp
    const cached = await this.loadFromCache();
    const cachedAt = cached?.cachedAt ?? Date.now();

    return { models, cachedAt };
  }

  /**
   * Refresh models from app-server and update cache
   *
   * Thread-safe: Deduplicates concurrent refresh requests
   */
  async refreshModels(): Promise<CodexModel[]> {
    // Deduplicate concurrent refresh requests
    if (this.inFlightRefresh) {
      return this.inFlightRefresh;
    }

    // Start new refresh
    this.inFlightRefresh = this.doRefresh();

    try {
      const models = await this.inFlightRefresh;
      return models;
    } finally {
      this.inFlightRefresh = null;
    }
  }

  /**
   * Clear the cache file
   */
  async clearCache(): Promise<void> {
    logger.info('[clearCache] Clearing cache...');

    try {
      await secureFs.unlink(this.cacheFilePath);
      logger.info('[clearCache] Cache cleared');
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        logger.error('[clearCache] Failed to clear cache:', error);
      }
    }
  }

  /**
   * Internal method to perform the actual refresh
   */
  private async doRefresh(): Promise<CodexModel[]> {
    try {
      // Check if app-server is available
      const isAvailable = await this.appServerService.isAvailable();
      if (!isAvailable) {
        return [];
      }

      // Fetch models from app-server
      const response = await this.appServerService.getModels();
      if (!response || !response.data) {
        return [];
      }

      // Transform models to UI format
      const models = response.data.map((model) => this.transformModel(model));

      // Save to cache
      await this.saveToCache(models);

      logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);

      return models;
    } catch (error) {
      logger.error('[doRefresh] Refresh failed:', error);
      return [];
    }
  }

  /**
   * Transform app-server model to UI-compatible format
   */
  private transformModel(appServerModel: AppServerModel): CodexModel {
    return {
      id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
      label: appServerModel.displayName,
      description: appServerModel.description,
      hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
      supportsVision: true, // All Codex models support vision
      tier: this.inferTier(appServerModel.id),
      isDefault: appServerModel.isDefault,
    };
  }

  /**
   * Infer tier from model ID
   */
  private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
    if (
      modelId.includes('max') ||
      modelId.includes('gpt-5.2-codex') ||
      modelId.includes('gpt-5.3-codex')
    ) {
      return 'premium';
    }
    if (modelId.includes('mini')) {
      return 'basic';
    }
    return 'standard';
  }

  /**
   * Load cache from disk
   */
  private async loadFromCache(): Promise<CodexModelCache | null> {
    try {
      const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
      const cache = JSON.parse(content.toString()) as CodexModelCache;

      // Validate cache structure
      if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
        logger.warn('[loadFromCache] Invalid cache structure, ignoring');
        return null;
      }

      return cache;
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        logger.warn('[loadFromCache] Failed to read cache:', error);
      }
      return null;
    }
  }

  /**
   * Save cache to disk (atomic write)
   */
  private async saveToCache(models: CodexModel[]): Promise<void> {
    const cache: CodexModelCache = {
      models,
      cachedAt: Date.now(),
      ttl: this.ttl,
    };

    const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;

    try {
      // Write to temp file
      const content = JSON.stringify(cache, null, 2);
      await secureFs.writeFile(tempPath, content, 'utf-8');

      // Atomic rename
      await secureFs.rename(tempPath, this.cacheFilePath);
    } catch (error) {
      logger.error('[saveToCache] Failed to save cache:', error);

      // Clean up temp file
      try {
        await secureFs.unlink(tempPath);
      } catch {
        // Ignore cleanup errors
      }
    }
  }
}