From 0357c8ce28d69975a9815a80b62742d4c81d1e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=A8=E5=86=89?= Date: Thu, 10 Sep 2026 10:44:06 +0800 Subject: [PATCH 1/3] feat(codex-models): read the Codex CLI models cache for ChatGPT-auth installs codex_local model discovery had two sources: the OpenAI models API (only when an OPENAI_API_KEY resolves) and a static array shipped with the adapter. Most Codex users authenticate the CLI with a ChatGPT account, so the API path is skipped and the picker shows only the static array, which changes only when Paperclip cuts a release. The Refresh button re-read the same array and changed nothing. Read the catalog the Codex CLI already maintains for itself at $CODEX_HOME/models_cache.json (default ~/.codex/models_cache.json) as the no-API-key discovery source. Filter to entries the CLI marks visibility "list" so internal slugs stay out of the picker, and merge with the static fallback through the existing dedupe/merge helpers so no id offered today disappears. The API-key path and the static fallback stay exactly as they are when the file is missing, unreadable, malformed, or lists nothing usable. Fixes #13126 --- server/src/adapters/codex-models.test.ts | 122 +++++++++++++++++++++++ server/src/adapters/codex-models.ts | 53 +++++++++- 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 server/src/adapters/codex-models.test.ts diff --git a/server/src/adapters/codex-models.test.ts b/server/src/adapters/codex-models.test.ts new file mode 100644 index 0000000000..c70c396175 --- /dev/null +++ b/server/src/adapters/codex-models.test.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; + +vi.mock("../config-file.js", () => ({ + readConfigFile: () => null, +})); + +import { listCodexModels, refreshCodexModels } from "./codex-models.js"; + +function writeCache(dir: string, payload: unknown): void { + fs.writeFileSync( + path.join(dir, "models_cache.json"), + typeof payload === "string" ? payload : JSON.stringify(payload), + ); +} + +function cacheEntry(overrides: Record): Record { + return { + slug: "gpt-test", + display_name: "GPT Test", + visibility: "list", + ...overrides, + }; +} + +describe("codex model discovery via the CLI models cache", () => { + let tempHome: string; + const originalCodexHome = process.env.CODEX_HOME; + const originalOpenAiKey = process.env.OPENAI_API_KEY; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-models-")); + process.env.CODEX_HOME = tempHome; + delete process.env.OPENAI_API_KEY; + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + if (originalOpenAiKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = originalOpenAiKey; + }); + + it("returns the static fallback when the cache file is missing", async () => { + const models = await listCodexModels(); + expect(models.map((m) => m.id)).toEqual( + [...codexFallbackModels.map((m) => m.id)].sort((a, b) => + a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }), + ), + ); + }); + + it("lists cache entries with visibility list and keeps internal slugs out", async () => { + writeCache(tempHome, { + models: [ + cacheEntry({ slug: "gpt-5.5", display_name: "GPT-5.5" }), + cacheEntry({ slug: "gpt-reserve", display_name: "GPT Reserve", visibility: "hide" }), + cacheEntry({ slug: "codex-auto-review", visibility: null }), + ], + }); + + const models = await listCodexModels(); + const ids = models.map((m) => m.id); + + expect(ids).toContain("gpt-5.5"); + expect(ids).not.toContain("gpt-reserve"); + expect(ids).not.toContain("codex-auto-review"); + // Static fallback ids still merge in. + for (const fallback of codexFallbackModels) { + expect(ids).toContain(fallback.id); + } + expect(models.find((m) => m.id === "gpt-5.5")?.label).toBe("GPT-5.5"); + }); + + it("falls back to the slug when a cache entry has no display name", async () => { + writeCache(tempHome, { + models: [cacheEntry({ slug: "o3-mini", display_name: "" })], + }); + + const models = await listCodexModels(); + expect(models.find((m) => m.id === "o3-mini")?.label).toBe("o3-mini"); + }); + + it("returns the static fallback when the cache file is malformed", async () => { + writeCache(tempHome, "{not json"); + + const models = await refreshCodexModels(); + expect(models.map((m) => m.id)).toEqual( + [...codexFallbackModels.map((m) => m.id)].sort((a, b) => + a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }), + ), + ); + }); + + it("returns the static fallback when the cache payload has no models array", async () => { + writeCache(tempHome, { fetched_at: "2026-09-09T00:00:00Z" }); + + const models = await listCodexModels(); + expect(models.length).toBe(codexFallbackModels.length); + }); + + it("ignores cache entries whose slug is missing or empty", async () => { + writeCache(tempHome, { + models: [ + cacheEntry({ slug: "", display_name: "Empty" }), + { display_name: "No slug", visibility: "list" }, + cacheEntry({ slug: "gpt-5" }), + ], + }); + + const models = await listCodexModels(); + const ids = models.map((m) => m.id); + expect(ids).toContain("gpt-5"); + expect(ids).not.toContain(""); + }); +}); diff --git a/server/src/adapters/codex-models.ts b/server/src/adapters/codex-models.ts index 872779144f..ec94912fe6 100644 --- a/server/src/adapters/codex-models.ts +++ b/server/src/adapters/codex-models.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import type { AdapterModel } from "./types.js"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; import { readConfigFile } from "../config-file.js"; @@ -31,6 +35,48 @@ function mergedWithFallback(models: AdapterModel[]): AdapterModel[] { ]).sort((a, b) => a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" })); } +/** + * Read the Codex CLI's own model catalog, `$CODEX_HOME/models_cache.json` + * (default `~/.codex/models_cache.json`). The CLI refreshes this file from + * the ChatGPT backend during normal use, so it is authoritative for exactly + * the ChatGPT-authenticated installs that the OpenAI API-key path cannot + * serve. Entries the CLI does not list in its picker (`visibility` other + * than "list", e.g. internal slugs) stay out of the catalog. + * + * 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[] { + 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 parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return []; + const entries = (parsed as { models?: unknown }).models; + if (!Array.isArray(entries)) return []; + + const models: AdapterModel[] = []; + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) continue; + const { slug, display_name: displayName, visibility } = entry as { + slug?: unknown; + display_name?: unknown; + visibility?: unknown; + }; + if (visibility !== "list") continue; + if (typeof slug !== "string" || slug.trim().length === 0) continue; + const label = + typeof displayName === "string" && displayName.trim().length > 0 + ? displayName.trim() + : slug.trim(); + models.push({ id: slug.trim(), label }); + } + return dedupeModels(models); + } catch { + return []; + } +} + function resolveOpenAiApiKey(): string | null { const envKey = process.env.OPENAI_API_KEY?.trim(); if (envKey) return envKey; @@ -73,8 +119,13 @@ async function fetchOpenAiModels(apiKey: string): Promise { async function loadCodexModels(options?: { forceRefresh?: boolean }): Promise { const forceRefresh = options?.forceRefresh === true; const apiKey = resolveOpenAiApiKey(); + if (!apiKey) { + // ChatGPT-authenticated installs have no API key. Their catalog lives in + // the Codex CLI's own models_cache.json, merged over the static fallback + // so no id that shipped in a release disappears. + return mergedWithFallback(readCodexModelsCache()); + } const fallback = dedupeModels(codexFallbackModels); - if (!apiKey) return fallback; const now = Date.now(); const keyFingerprint = fingerprint(apiKey); From fb556de3ca5eced04ec7b2ff2721c2e86cb1a7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=A8=E5=86=89?= Date: Thu, 10 Sep 2026 10:51:33 +0800 Subject: [PATCH 2/3] perf(codex-models): cache the parsed CLI catalog behind the TTL lifecycle Greptile: the no-API-key path synchronously reread and reparsed models_cache.json on every listing. Reuse the module's existing TTL pattern: ordinary listings serve the parsed catalog for 60s keyed by the resolved cache path, and refreshCodexModels (the Refresh button) bypasses the cache and re-reads the file. --- server/src/adapters/codex-models.test.ts | 19 ++++++++++++++++- server/src/adapters/codex-models.ts | 26 ++++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) 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; } From 8bd517e5842d95410c0dc34621a6c91fba0692bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=A8=E5=86=89?= Date: Thu, 10 Sep 2026 11:04:27 +0800 Subject: [PATCH 3/3] fix(codex-models): keep fallback order so a missing cache degrades exactly CI: adapter-models.test.ts pins toEqual(codexFallbackModels), including the static array's original order. The no-key path went through mergedWithFallback, which sorts; with an empty CLI cache that reordered the fallback and broke two existing tests. Merge without sorting so a missing or unusable cache returns the pre-change result bit-for-bit. --- server/src/adapters/codex-models.test.ts | 16 ++++------------ server/src/adapters/codex-models.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/server/src/adapters/codex-models.test.ts b/server/src/adapters/codex-models.test.ts index eeed556174..a20d884f2c 100644 --- a/server/src/adapters/codex-models.test.ts +++ b/server/src/adapters/codex-models.test.ts @@ -48,13 +48,9 @@ describe("codex model discovery via the CLI models cache", () => { else process.env.OPENAI_API_KEY = originalOpenAiKey; }); - it("returns the static fallback when the cache file is missing", async () => { + it("returns the static fallback in its original order when the cache file is missing", async () => { const models = await listCodexModels(); - expect(models.map((m) => m.id)).toEqual( - [...codexFallbackModels.map((m) => m.id)].sort((a, b) => - a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }), - ), - ); + expect(models).toEqual(codexFallbackModels); }); it("lists cache entries with visibility list and keeps internal slugs out", async () => { @@ -88,15 +84,11 @@ describe("codex model discovery via the CLI models cache", () => { expect(models.find((m) => m.id === "o3-mini")?.label).toBe("o3-mini"); }); - it("returns the static fallback when the cache file is malformed", async () => { + it("returns the static fallback in its original order when the cache file is malformed", async () => { writeCache(tempHome, "{not json"); const models = await refreshCodexModels(); - expect(models.map((m) => m.id)).toEqual( - [...codexFallbackModels.map((m) => m.id)].sort((a, b) => - a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }), - ), - ); + expect(models).toEqual(codexFallbackModels); }); it("returns the static fallback when the cache payload has no models array", async () => { diff --git a/server/src/adapters/codex-models.ts b/server/src/adapters/codex-models.ts index 2b7b072976..a896f9328e 100644 --- a/server/src/adapters/codex-models.ts +++ b/server/src/adapters/codex-models.ts @@ -139,8 +139,13 @@ async function loadCodexModels(options?: { forceRefresh?: boolean }): Promise