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
This commit is contained in:
馨冉 2026-09-10 10:44:06 +08:00
parent bd1fdc2887
commit 0357c8ce28
2 changed files with 174 additions and 1 deletions

View File

@ -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<string, unknown>): Record<string, unknown> {
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("");
});
});

View File

@ -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<AdapterModel[]> {
async function loadCodexModels(options?: { forceRefresh?: boolean }): Promise<AdapterModel[]> {
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);