From 5a1be6c8af77507234d158a4a6e2ad67534344a1 Mon Sep 17 00:00:00 2001 From: hbc Date: Fri, 10 Jul 2026 00:33:06 -0700 Subject: [PATCH 1/2] feat: refresh pi model catalog --- bin/ai.js | 13 +- docs/models-and-config.md | 2 + package.json | 2 +- src/catalog.js | 263 ++++++++++++++++++++++++++++++++++++++ src/index.js | 4 +- test/catalog.test.js | 67 ++++++++++ 6 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 src/catalog.js create mode 100644 test/catalog.test.js diff --git a/bin/ai.js b/bin/ai.js index 7b34794..b801f88 100755 --- a/bin/ai.js +++ b/bin/ai.js @@ -7,6 +7,7 @@ import path from "node:path"; import readline from "node:readline/promises"; import { stdin as input } from "node:process"; import { clearShellProfileFork, getConfigPath, getShellSessionDir, readConfig, readShellProfileState, writeConfig, writeShellProfile } from "../src/config.js"; +import { getRegistryCatalogPath, refreshCatalog, releaseCatalogRefreshLock, startCatalogRefresh } from "../src/catalog.js"; import { isPlainObject, validateThinkingLevel } from "../src/model-config.js"; const require = createRequire(import.meta.url); @@ -747,7 +748,8 @@ async function askYesNo(question, defaultValue = true) { async function createLoadedRegistry() { const { AuthStorage, ModelRegistry } = await import("@earendil-works/pi-coding-agent"); const authStorage = AuthStorage.create(); - return { authStorage, modelRegistry: ModelRegistry.create(authStorage) }; + startCatalogRefresh(); + return { authStorage, modelRegistry: ModelRegistry.create(authStorage, getRegistryCatalogPath()) }; } function getProviderRows(modelRegistry, { configuredOnly = false } = {}) { @@ -1204,6 +1206,15 @@ function printShellInit({ shellName, wrapperName, commandName = "ai" }) { } async function main() { + if (process.argv.slice(2).join(" ") === "--refresh-model-catalog") { + try { + await refreshCatalog(); + } finally { + releaseCatalogRefreshLock(); + } + return; + } + let parsed; try { diff --git a/docs/models-and-config.md b/docs/models-and-config.md index 294749e..9b1ee0c 100644 --- a/docs/models-and-config.md +++ b/docs/models-and-config.md @@ -2,6 +2,8 @@ Use `-m` or `--model` to override the model for one invocation. Use `--thinking` to override the thinking level: +`ai` refreshes pi's generated model catalog in the background when a command needs models. The command always uses the last valid cached catalog, so a newly published model is normally available on the next invocation. The cache is refreshed at most every six hours and is stored under `@b4fun-ai/catalog/models.json` in the same state root as your config. + ```bash ai -m github-copilot/gpt-5.4-mini summarize this repository ai -m anthropic/claude-sonnet-4-5 --thinking high think through this migration diff --git a/package.json b/package.json index dfd9717..df00ec2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ ], "scripts": { "start": "node bin/ai.js", - "check": "node --check src/index.js && node --check src/auth.js && node --check src/config.js && node --check src/model-config.js && node --check src/profiles.js && node --check bin/ai.js && node --check scripts/build-sea.mjs", + "check": "node --check src/index.js && node --check src/auth.js && node --check src/config.js && node --check src/catalog.js && node --check src/model-config.js && node --check src/profiles.js && node --check bin/ai.js && node --check scripts/build-sea.mjs", "test": "node --test", "build:sea": "node scripts/build-sea.mjs", "build": "npm run build:sea" diff --git a/src/catalog.js b/src/catalog.js new file mode 100644 index 0000000..f6fe88f --- /dev/null +++ b/src/catalog.js @@ -0,0 +1,263 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getB4funAiHome } from "./config.js"; + +const CATALOG_REF = "main"; +const CATALOG_URL = "https://raw.githubusercontent.com/earendil-works/pi-mono"; +const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; +const REFRESH_LOCK_TTL_MS = 5 * 60 * 1000; + +export function getCatalogPath() { + return path.join(getB4funAiHome(), "catalog", "models.json"); +} + +function getPiModelsPath() { + return path.join(process.env.PI_CODING_AGENT_DIR || os.homedir(), process.env.PI_CODING_AGENT_DIR ? "models.json" : ".pi/agent/models.json"); +} + +function stripJsonComments(input) { + return input + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => (match[0] === '"' ? match : "")) + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (match, tail) => tail ?? (match[0] === '"' ? match : "")); +} + +function writeJsonAtomically(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); +} + +/** + * Combine the cached catalog with pi's existing custom models without changing + * pi's files. Custom provider settings and duplicate model IDs take precedence. + */ +export function getRegistryCatalogPath() { + const catalogPath = getCatalogPath(); + const registryPath = path.join(path.dirname(catalogPath), "registry-models.json"); + let catalog; + try { + catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")); + } catch { + return getPiModelsPath(); + } + + let custom = {}; + try { + custom = JSON.parse(stripJsonComments(fs.readFileSync(getPiModelsPath(), "utf8"))); + } catch { + // pi will continue to report malformed custom configuration when used directly. + } + const customProviders = custom?.providers && typeof custom.providers === "object" ? custom.providers : {}; + const providers = { ...catalog.providers }; + for (const [provider, customConfig] of Object.entries(customProviders)) { + const catalogConfig = providers[provider] ?? {}; + const customModels = Array.isArray(customConfig.models) ? customConfig.models : []; + const customIds = new Set(customModels.map((model) => model?.id)); + providers[provider] = { + ...catalogConfig, + ...customConfig, + models: [...(catalogConfig.models ?? []).filter((model) => !customIds.has(model.id)), ...customModels], + }; + } + writeJsonAtomically(registryPath, { providers }); + return registryPath; +} + +function getRefreshStampPath() { + return path.join(getB4funAiHome(), "catalog", "last-refresh"); +} + +function findObjectEnd(source, start) { + let depth = 0; + let quote; + let escaped = false; + + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return index + 1; + } + } + throw new Error("Unterminated model catalog object"); +} + +function parseJsonLikeObject(source) { + const withoutAssertions = source + .replace(/\s+satisfies\s+Model<[^>]+>/g, "") + .replace(/,\s*([}\]])/g, "$1"); + let normalized = ""; + let quote; + let escaped = false; + for (let index = 0; index < withoutAssertions.length; index += 1) { + const char = withoutAssertions[index]; + if (quote) { + normalized += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'") { + quote = char; + normalized += char; + continue; + } + if (char === "{" || char === ",") { + const key = /^(\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)/.exec(withoutAssertions.slice(index + 1)); + if (key) { + normalized += `${char}${key[1]}"${key[2]}"${key[3]}`; + index += key[0].length; + continue; + } + } + normalized += char; + } + return JSON.parse(normalized); +} + +/** Parse the generated catalog index into provider source paths. */ +export function parseCatalogIndex(source) { + const imports = new Map(); + for (const match of source.matchAll(/import\s*\{\s*(\w+)\s*\}\s*from\s*"\.\/(providers\/[^"\n]+)";/g)) { + imports.set(match[1], match[2]); + } + + const modelsMatch = /export const MODELS\s*=\s*\{([\s\S]*?)\}\s*as const/.exec(source); + if (!modelsMatch) throw new Error("Could not find MODELS in generated catalog"); + + return [...modelsMatch[1].matchAll(/"([^"]+)"\s*:\s*(\w+)/g)].map(([, provider, identifier]) => { + const sourcePath = imports.get(identifier); + if (!sourcePath) throw new Error(`Missing source import for provider ${provider}`); + return { provider, sourcePath }; + }); +} + +/** Parse one generated provider module. Its exported object is JSON plus TypeScript assertions. */ +export function parseProviderModels(source) { + const assignment = /export const \w+\s*=\s*\{/.exec(source); + if (!assignment) throw new Error("Could not find provider model object"); + const start = assignment.index + assignment[0].lastIndexOf("{"); + return Object.values(parseJsonLikeObject(source.slice(start, findObjectEnd(source, start)))); +} + +function catalogRequestUrl(sourcePath, ref = CATALOG_REF) { + return `${CATALOG_URL}/${encodeURIComponent(ref)}/packages/ai/src/${sourcePath}`; +} + +function toCatalogModel(model) { + const result = { + id: model.id, + name: model.name, + api: model.api, + baseUrl: model.baseUrl, + reasoning: model.reasoning, + input: model.input, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + }; + if (model.cost) { + result.cost = { + input: model.cost.input, + output: model.cost.output, + cacheRead: model.cost.cacheRead, + cacheWrite: model.cost.cacheWrite, + }; + } + if (model.thinkingLevelMap) { + result.thinkingLevelMap = Object.fromEntries( + Object.entries(model.thinkingLevelMap).filter(([level]) => ["off", "minimal", "low", "medium", "high", "xhigh"].includes(level)), + ); + } + return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined && value !== "")); +} + +async function fetchText(url, fetchImpl) { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Catalog request failed (${response.status}): ${url}`); + return response.text(); +} + +export async function refreshCatalog({ fetchImpl = fetch, ref = CATALOG_REF } = {}) { + const indexUrl = `${CATALOG_URL}/${encodeURIComponent(ref)}/packages/ai/src/models.generated.ts`; + const index = parseCatalogIndex(await fetchText(indexUrl, fetchImpl)); + const entries = await Promise.all(index.map(async ({ provider, sourcePath }) => ({ + provider, + models: parseProviderModels(await fetchText(catalogRequestUrl(sourcePath, ref), fetchImpl)).map(toCatalogModel), + }))); + const providers = Object.fromEntries(entries.map(({ provider, models }) => [provider, { models }])); + const catalogPath = getCatalogPath(); + writeJsonAtomically(catalogPath, { providers }); + fs.writeFileSync(getRefreshStampPath(), String(Date.now()), { mode: 0o600 }); + return { providers: entries.length, models: entries.reduce((total, entry) => total + entry.models.length, 0) }; +} + +function shouldRefresh() { + try { + const lastRefresh = Number(fs.readFileSync(getRefreshStampPath(), "utf8")); + return !Number.isFinite(lastRefresh) || Date.now() - lastRefresh >= REFRESH_INTERVAL_MS; + } catch { + return true; + } +} + +function getRefreshLockPath() { + return path.join(getB4funAiHome(), "catalog", "refresh.lock"); +} + +function acquireRefreshLock() { + const lockPath = getRefreshLockPath(); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + try { + const age = Date.now() - fs.statSync(lockPath).mtimeMs; + if (age < REFRESH_LOCK_TTL_MS) return false; + fs.unlinkSync(lockPath); + } catch (error) { + if (error?.code !== "ENOENT") return false; + } + try { + fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 }); + return true; + } catch { + return false; + } +} + +export function releaseCatalogRefreshLock() { + try { + fs.unlinkSync(getRefreshLockPath()); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} + +/** Start an unobserved refresh. The current invocation always uses the last valid cache. */ +export function startCatalogRefresh() { + if (!shouldRefresh() || process.env.B4FUN_AI_CATALOG_REFRESH === "1" || !acquireRefreshLock()) return; + const isSea = process.versions.sea !== undefined; + const args = isSea ? ["--refresh-model-catalog"] : [process.argv[1], "--refresh-model-catalog"]; + try { + const child = spawn(process.execPath, args, { + detached: true, + stdio: "ignore", + env: { ...process.env, B4FUN_AI_CATALOG_REFRESH: "1" }, + }); + child.unref(); + } catch { + releaseCatalogRefreshLock(); + } +} diff --git a/src/index.js b/src/index.js index d4fb5d3..c6028a1 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import { readShellProfile, } from "./config.js"; import { resolveConfiguredModel } from "./model-config.js"; +import { getRegistryCatalogPath, startCatalogRefresh } from "./catalog.js"; import { buildProfiledPrompt, getProfileAppendSystemPrompt, @@ -190,7 +191,8 @@ export async function ask(askLlm, options = {}) { const profileAppendSystemPrompt = getProfileAppendSystemPrompt(profile); const tools = getProfileTools(profile) ?? ["read", "bash", "edit", "write", "foreground"]; const authStorage = AuthStorage.create(); - const modelRegistry = ModelRegistry.create(authStorage); + startCatalogRefresh(); + const modelRegistry = ModelRegistry.create(authStorage, getRegistryCatalogPath()); const model = resolveModel(modelRegistry, modelSpec); const sessionManager = SessionManager.continueRecent(cwd, sessionDir); const existingContext = sessionManager.buildSessionContext(); diff --git a/test/catalog.test.js b/test/catalog.test.js new file mode 100644 index 0000000..5af5d5e --- /dev/null +++ b/test/catalog.test.js @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { parseCatalogIndex, parseProviderModels } from "../src/catalog.js"; + +const node = process.execPath; +const cli = new URL("../bin/ai.js", import.meta.url).pathname; + +test("parses generated catalog index and provider model objects", () => { + const index = parseCatalogIndex(` + import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts"; + export const MODELS = { "openai-codex": OPENAI_CODEX_MODELS } as const; + `); + assert.deepEqual(index, [{ provider: "openai-codex", sourcePath: "providers/openai-codex.models.ts" }]); + + const models = parseProviderModels(` + export const OPENAI_CODEX_MODELS = { + "gpt-5.6-terra": { + id: "gpt-5.6-terra", name: "GPT-5.6 Terra", api: "openai-codex-responses", + provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", reasoning: true, + thinkingLevelMap: { "xhigh": "xhigh", "max": "max" }, input: ["text", "image"], + cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }, contextWindow: 372000, maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + } as const; + `); + assert.equal(models[0].id, "gpt-5.6-terra"); +}); + +test("lists models from the cached dynamic catalog", () => { + const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), "ai-catalog-")); + const catalogDir = path.join(xdgHome, "@b4fun-ai", "catalog"); + fs.mkdirSync(catalogDir, { recursive: true }); + fs.writeFileSync(path.join(catalogDir, "models.json"), `${JSON.stringify({ + providers: { + "openai-codex": { + models: [{ + id: "gpt-5.6-terra", name: "GPT-5.6 Terra", api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", reasoning: true, input: ["text", "image"], + cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }, contextWindow: 372000, maxTokens: 128000, + }], + }, + }, + })}\n`); + const piAgentDir = path.join(xdgHome, "pi-agent"); + fs.mkdirSync(piAgentDir, { recursive: true }); + fs.writeFileSync(path.join(piAgentDir, "models.json"), `{ + // pi accepts comments and trailing commas in models.json. + "providers": { + "openai-codex": { "models": [{ "id": "gpt-5.6-terra", "name": "My Terra" }], }, + }, + }\n`); + + const output = execFileSync(node, [cli, "models", "--all"], { + encoding: "utf8", + env: { + ...process.env, + XDG_HOME: xdgHome, + PI_CODING_AGENT_DIR: piAgentDir, + B4FUN_AI_CATALOG_REFRESH: "1", + }, + }); + assert.match(output, /openai-codex\/gpt-5\.6-terra/); + assert.match(output, /My Terra/); +}); From a34fbbc2c25e1cc77864962c6db0f6e968f5ab85 Mon Sep 17 00:00:00 2001 From: hbc Date: Fri, 10 Jul 2026 15:31:35 -0700 Subject: [PATCH 2/2] fix: preserve dynamic catalog metadata --- src/catalog.js | 4 +++- test/catalog.test.js | 48 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/catalog.js b/src/catalog.js index f6fe88f..526ac3b 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -165,6 +165,7 @@ function toCatalogModel(model) { name: model.name, api: model.api, baseUrl: model.baseUrl, + compat: model.compat, reasoning: model.reasoning, input: model.input, contextWindow: model.contextWindow, @@ -176,11 +177,12 @@ function toCatalogModel(model) { output: model.cost.output, cacheRead: model.cost.cacheRead, cacheWrite: model.cost.cacheWrite, + tiers: model.cost.tiers, }; } if (model.thinkingLevelMap) { result.thinkingLevelMap = Object.fromEntries( - Object.entries(model.thinkingLevelMap).filter(([level]) => ["off", "minimal", "low", "medium", "high", "xhigh"].includes(level)), + Object.entries(model.thinkingLevelMap).filter(([level]) => ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(level)), ); } return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined && value !== "")); diff --git a/test/catalog.test.js b/test/catalog.test.js index 5af5d5e..dd7f7a0 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -4,7 +4,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { parseCatalogIndex, parseProviderModels } from "../src/catalog.js"; +import { getRegistryCatalogPath, parseCatalogIndex, parseProviderModels, refreshCatalog } from "../src/catalog.js"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; const node = process.execPath; const cli = new URL("../bin/ai.js", import.meta.url).pathname; @@ -20,15 +21,56 @@ test("parses generated catalog index and provider model objects", () => { export const OPENAI_CODEX_MODELS = { "gpt-5.6-terra": { id: "gpt-5.6-terra", name: "GPT-5.6 Terra", api: "openai-codex-responses", - provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", reasoning: true, + provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", compat: { supportsToolSearch: true }, reasoning: true, thinkingLevelMap: { "xhigh": "xhigh", "max": "max" }, input: ["text", "image"], - cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }, contextWindow: 372000, maxTokens: 128000, + cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125, + tiers: [{ inputTokensAbove: 272000, input: 5, output: 22.5, cacheRead: 0.5, cacheWrite: 6.25 }] }, + contextWindow: 372000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, } as const; `); assert.equal(models[0].id, "gpt-5.6-terra"); }); +test("loads generated compatibility, tiered costs, and future thinking metadata", async () => { + const previousXdgHome = process.env.XDG_HOME; + const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), "ai-catalog-refresh-")); + process.env.XDG_HOME = xdgHome; + const indexSource = ` + import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts"; + export const MODELS = { "openai-codex": OPENAI_CODEX_MODELS } as const; + `; + const providerSource = ` + export const OPENAI_CODEX_MODELS = { + terra: { + id: "terra", name: "Terra", api: "openai-codex-responses", provider: "openai-codex", + baseUrl: "https://example.test", compat: { supportsToolSearch: true }, reasoning: true, + thinkingLevelMap: { xhigh: "xhigh", max: "max" }, input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2, + tiers: [{ inputTokensAbove: 100, input: 2, output: 4, cacheRead: 0.2, cacheWrite: 0.4 }] }, + contextWindow: 200, maxTokens: 100, + } satisfies Model<"openai-codex-responses">, + } as const; + `; + const fetchImpl = async (url) => ({ + ok: true, + text: async () => url.endsWith("models.generated.ts") ? indexSource : providerSource, + }); + + try { + await refreshCatalog({ fetchImpl }); + const registry = ModelRegistry.create(AuthStorage.create(), getRegistryCatalogPath()); + const model = registry.find("openai-codex", "terra"); + assert.equal(registry.getError(), undefined); + assert.deepEqual(model.compat, { supportsToolSearch: true }); + assert.equal(model.cost.tiers[0].inputTokensAbove, 100); + assert.equal(model.thinkingLevelMap.max, "max"); + } finally { + if (previousXdgHome === undefined) delete process.env.XDG_HOME; + else process.env.XDG_HOME = previousXdgHome; + } +}); + test("lists models from the cached dynamic catalog", () => { const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), "ai-catalog-")); const catalogDir = path.join(xdgHome, "@b4fun-ai", "catalog");