Merge 8bd517e584 into c9e3bb7ca4
This commit is contained in:
commit
bfbfaf2954
|
|
@ -0,0 +1,131 @@
|
|||
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, resetCodexModelsCacheForTests } 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;
|
||||
resetCodexModelsCacheForTests();
|
||||
});
|
||||
|
||||
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 in its original order when the cache file is missing", async () => {
|
||||
const models = await listCodexModels();
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
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 in its original order when the cache file is malformed", async () => {
|
||||
writeCache(tempHome, "{not json");
|
||||
|
||||
const models = await refreshCodexModels();
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -7,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)}`;
|
||||
|
|
@ -31,6 +36,64 @@ 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(options?: { forceRefresh?: boolean }): AdapterModel[] {
|
||||
try {
|
||||
const codexHome = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
const catalog = dedupeModels(models);
|
||||
codexCache = {
|
||||
cachePath,
|
||||
expiresAt: now + OPENAI_MODELS_CACHE_TTL_MS,
|
||||
models: catalog,
|
||||
};
|
||||
return catalog;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenAiApiKey(): string | null {
|
||||
const envKey = process.env.OPENAI_API_KEY?.trim();
|
||||
if (envKey) return envKey;
|
||||
|
|
@ -73,8 +136,18 @@ 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. Keep the fallback's
|
||||
// original order (no sort) so a missing cache degrades to exactly the
|
||||
// pre-change result.
|
||||
return dedupeModels([
|
||||
...readCodexModelsCache({ forceRefresh }),
|
||||
...codexFallbackModels,
|
||||
]);
|
||||
}
|
||||
const fallback = dedupeModels(codexFallbackModels);
|
||||
if (!apiKey) return fallback;
|
||||
|
||||
const now = Date.now();
|
||||
const keyFingerprint = fingerprint(apiKey);
|
||||
|
|
@ -110,4 +183,5 @@ export async function refreshCodexModels(): Promise<AdapterModel[]> {
|
|||
|
||||
export function resetCodexModelsCacheForTests() {
|
||||
cached = null;
|
||||
codexCache = null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue