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.
This commit is contained in:
馨冉 2026-09-10 10:51:33 +08:00
parent 0357c8ce28
commit fb556de3ca
2 changed files with 40 additions and 5 deletions

View File

@ -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");
});
});

View File

@ -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<Ad
// 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());
return mergedWithFallback(readCodexModelsCache({ forceRefresh }));
}
const fallback = dedupeModels(codexFallbackModels);
@ -161,4 +178,5 @@ export async function refreshCodexModels(): Promise<AdapterModel[]> {
export function resetCodexModelsCacheForTests() {
cached = null;
codexCache = null;
}