diff --git a/server/src/adapters/codex-models.test.ts b/server/src/adapters/codex-models.test.ts index c70c396175..eeed556174 100644 --- a/server/src/adapters/codex-models.test.ts +++ b/server/src/adapters/codex-models.test.ts @@ -10,7 +10,7 @@ vi.mock("../config-file.js", () => ({ readConfigFile: () => null, })); -import { listCodexModels, refreshCodexModels } from "./codex-models.js"; +import { listCodexModels, refreshCodexModels, resetCodexModelsCacheForTests } from "./codex-models.js"; function writeCache(dir: string, payload: unknown): void { fs.writeFileSync( @@ -37,6 +37,7 @@ describe("codex model discovery via the CLI models cache", () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-models-")); process.env.CODEX_HOME = tempHome; delete process.env.OPENAI_API_KEY; + resetCodexModelsCacheForTests(); }); afterEach(() => { @@ -119,4 +120,20 @@ describe("codex model discovery via the CLI models cache", () => { expect(ids).toContain("gpt-5"); expect(ids).not.toContain(""); }); + + it("serves repeated listings from the TTL cache and refreshes on demand", async () => { + writeCache(tempHome, { models: [cacheEntry({ slug: "gpt-5.5" })] }); + const first = await listCodexModels(); + expect(first.map((m) => m.id)).toContain("gpt-5.5"); + + // Rewrite the file: an ordinary listing still serves the cached catalog, + // while an explicit refresh re-reads the file. + writeCache(tempHome, { models: [cacheEntry({ slug: "gpt-6-new" })] }); + const second = await listCodexModels(); + expect(second.map((m) => m.id)).toContain("gpt-5.5"); + expect(second.map((m) => m.id)).not.toContain("gpt-6-new"); + + const refreshed = await refreshCodexModels(); + expect(refreshed.map((m) => m.id)).toContain("gpt-6-new"); + }); }); diff --git a/server/src/adapters/codex-models.ts b/server/src/adapters/codex-models.ts index ec94912fe6..2b7b072976 100644 --- a/server/src/adapters/codex-models.ts +++ b/server/src/adapters/codex-models.ts @@ -11,6 +11,7 @@ const OPENAI_MODELS_TIMEOUT_MS = 5000; const OPENAI_MODELS_CACHE_TTL_MS = 60_000; let cached: { keyFingerprint: string; expiresAt: number; models: AdapterModel[] } | null = null; +let codexCache: { cachePath: string; expiresAt: number; models: AdapterModel[] } | null = null; function fingerprint(apiKey: string): string { return `${apiKey.length}:${apiKey.slice(-6)}`; @@ -46,10 +47,20 @@ function mergedWithFallback(models: AdapterModel[]): AdapterModel[] { * Returns an empty list when the file is missing, unreadable, malformed, or * lists nothing usable — the caller merges the static fallback either way. */ -function readCodexModelsCache(): AdapterModel[] { +function readCodexModelsCache(options?: { forceRefresh?: boolean }): AdapterModel[] { try { const codexHome = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex"); - const raw = fs.readFileSync(path.join(codexHome, "models_cache.json"), "utf8"); + const cachePath = path.join(codexHome, "models_cache.json"); + const now = Date.now(); + if ( + !options?.forceRefresh + && codexCache + && codexCache.cachePath === cachePath + && codexCache.expiresAt > now + ) { + return codexCache.models; + } + const raw = fs.readFileSync(cachePath, "utf8"); const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null) return []; const entries = (parsed as { models?: unknown }).models; @@ -71,7 +82,13 @@ function readCodexModelsCache(): AdapterModel[] { : slug.trim(); models.push({ id: slug.trim(), label }); } - return dedupeModels(models); + const catalog = dedupeModels(models); + codexCache = { + cachePath, + expiresAt: now + OPENAI_MODELS_CACHE_TTL_MS, + models: catalog, + }; + return catalog; } catch { return []; } @@ -123,7 +140,7 @@ async function loadCodexModels(options?: { forceRefresh?: boolean }): Promise { export function resetCodexModelsCacheForTests() { cached = null; + codexCache = null; }