feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* @file A catalog tool's own config files, viewed and edited from its
* Settings tab. The files are ONLY those a trusted catalog file lists in
* `config` (built-in or the synced release copy — never a request, never
* `DASHBOARD_AI_TOOLS_DIR`), under the user's home. Only an existing regular
* text file ≤ 1 MB is read or written, and never one whose name (or whose
* symlink target's name) looks like a credential. A save must match the sha
* the editor opened (`ESTALE`), is syntax-checked for JSON/JSONC, backs the
* old text up under `<data dir>/ai-tool-config-backups/<id>/` (newest 10
* kept), and replaces the file atomically keeping its mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const aiTools = require("./ai-tools");
const { getDataDir } = require("./claude-home");
const MAX_BYTES = 1024 * 1024;
const KEEP_BACKUPS = 10;
const SECRET_RE = aiTools.CONFIG_SECRET_RE;
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
const sha = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
const expand = (p) => path.join(os.homedir(), p.slice(2));
/** Declared files with where they live now and whether they exist. */
function listConfig(id) {
const t = aiTools.get(id);
if (!t) throw fail("ENOTOOL", "unknown AI tool");
return t.config.map((c, index) => {
const full = expand(c.path);
let st = null;
try {
st = fs.statSync(full);
} catch {
/* missing */
}
return {
index,
path: full,
format: c.format,
exists: Boolean(st?.isFile()),
size: st?.isFile() ? st.size : null,
};
});
}
/** The declared file at `index`, resolved through any symlink and checked. */
function target(id, index) {
const files = listConfig(id);
const f = Number.isInteger(index) ? files[index] : undefined;
if (!f) throw fail("ENOFILE", "no such config file for this tool");
let real;
try {
real = fs.realpathSync(f.path);
} catch {
throw fail("ENOENT", "the file does not exist");
}
if (SECRET_RE.test(path.basename(real))) throw fail("ESECRET", "refusing a credential file");
const st = fs.statSync(real);
if (!st.isFile()) throw fail("ENOENT", "not a regular file");
if (st.size > MAX_BYTES) throw fail("ETOOBIG", "the file is larger than 1 MB");
return { ...f, real, mode: st.mode & 0o777 };
}
function readConfig(id, index) {
const f = target(id, index);
const buf = fs.readFileSync(f.real);
if (buf.includes(0)) throw fail("EBINARY", "not a text file");
return { path: f.path, format: f.format, text: buf.toString("utf8"), sha: sha(buf) };
}
/** JSONC → JSON: drop comments and trailing commas outside strings. */
function stripJsonc(s) {
let out = "";
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"') {
const start = i;
for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === "\\") i++;
out += s.slice(start, i + 1);
} else if (c === "/" && s[i + 1] === "/") {
while (i < s.length && s[i] !== "\n") i++;
out += "\n";
} else if (c === "/" && s[i + 1] === "*") {
const end = s.indexOf("*/", i + 2);
i = end < 0 ? s.length : end + 1;
} else out += c;
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
function checkSyntax(format, text) {
if (format === "text") return;
try {
JSON.parse(format === "jsonc" ? stripJsonc(text) : text);
} catch (err) {
throw fail("EBADSYNTAX", `not valid ${format.toUpperCase()}: ${err.message}`);
}
}
function backup(id, f, old) {
const dir = path.join(getDataDir(), "ai-tool-config-backups", id);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700); // the backups may hold keys; mode only applies on create
const name = path.basename(f.path);
const file = path.join(dir, `${name}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`);
fs.writeFileSync(file, old, { mode: 0o600 });
const mine = fs
.readdirSync(dir)
.filter((n) => n.startsWith(`${name}.`) && n.endsWith(".bak"))
.sort();
for (const n of mine.slice(0, -KEEP_BACKUPS)) fs.rmSync(path.join(dir, n), { force: true });
return file;
}
function writeConfig(id, index, text, baseSha) {
if (typeof text !== "string") throw fail("EBADFIELD", "text must be a string");
if (Buffer.byteLength(text) > MAX_BYTES) throw fail("ETOOBIG", "the text is larger than 1 MB");
if (text.includes("\0")) throw fail("EBINARY", "not a text file");
const f = target(id, index);
const old = fs.readFileSync(f.real);
if (old.includes(0)) throw fail("EBINARY", "not a text file");
if (baseSha !== sha(old)) throw fail("ESTALE", "the file changed since it was opened");
checkSyntax(f.format, text);
const saved = backup(id, f, old);
// A random name created exclusively (`wx`): an existing file or symlink at
// that path is refused, never written through.
const tmp = path.join(
path.dirname(f.real),
`.${path.basename(f.real)}.agenthub-${crypto.randomBytes(6).toString("hex")}`
);
fs.writeFileSync(tmp, text, { mode: f.mode, flag: "wx" });
try {
fs.chmodSync(tmp, f.mode);
fs.renameSync(tmp, f.real);
} catch (err) {
fs.rmSync(tmp, { force: true });
throw err;
}
return { sha: sha(Buffer.from(text)), backup: saved };
}
module.exports = { listConfig, readConfig, writeConfig };
+115
View File
@@ -0,0 +1,115 @@
/**
* @file The model choices a run of one AI tool offers, each from that tool's
* own source — never a hardcoded list that goes stale:
* - **codex**: Codex's own `$CODEX_HOME/models_cache.json` (what this account
* and this Codex build can use), `visibility: "list"` only, by `priority`.
* Only slug/name/description are read — the file also holds the account's
* identity, which never leaves this module.
* - **claude**: the CLI's aliases (`claude --model sonnet` follows the newest
* release), each named after the newest version the pricing table knows,
* then every priced `claude-<name>-<version>` to pin one.
* - **a catalog tool with a `model` flag**: its vendor's priced models
* (`model.vendor`) plus every model its own sessions reported.
* - anything else: no picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const aiTools = require("./ai-tools");
const { getCodexHome } = require("./codex-home");
const CLAUDE_ALIASES = [
["fable", "Fable"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
];
const VENDOR_PREFIX = { claude: "claude-", openai: "gpt-", google: "gemini-" };
/** `claude-sonnet-5-5` → [5, 5]; null for anything not `claude-<name>-<n>[-<n>]`. */
function claudeVersion(id, name) {
const m = new RegExp(`^claude-${name}-(\\d+)(?:-(\\d+))?$`).exec(id);
return m ? [Number(m[1]), Number(m[2] || 0)] : null;
}
const byVersionDesc = (a, b) => b.v[0] - a.v[0] || b.v[1] - a.v[1];
/** Priced families (`pattern` minus its `%`) that are bare ids, not globs. */
function pricedIds(db) {
return db
.prepare("SELECT model_pattern, display_name FROM model_pricing")
.all()
.filter((r) => /^[a-z0-9][a-z0-9.-]*%$/.test(r.model_pattern))
.map((r) => ({ id: r.model_pattern.slice(0, -1), label: r.display_name }));
}
function codexModels() {
try {
const cache = JSON.parse(
fs.readFileSync(path.join(getCodexHome(), "models_cache.json"), "utf8")
);
return (Array.isArray(cache.models) ? cache.models : [])
.filter((m) => m && m.visibility === "list" && aiTools.validModel(m.slug))
.sort((a, b) => (a.priority ?? 1e9) - (b.priority ?? 1e9))
.map((m) => ({
id: m.slug,
label: typeof m.display_name === "string" ? m.display_name : m.slug,
...(typeof m.description === "string" ? { hint: m.description } : {}),
}));
} catch {
return [];
}
}
function claudeModels(db) {
const priced = pricedIds(db);
const aliases = [];
const pinned = [];
for (const [alias, name] of CLAUDE_ALIASES) {
const versions = priced
.map((p) => ({ ...p, v: claudeVersion(p.id, alias) }))
.filter((p) => p.v)
.sort(byVersionDesc);
const newest = versions[0];
aliases.push({
id: alias,
label: newest ? `${name} (latest · ${newest.v[0]}.${newest.v[1]})` : `${name} (latest)`,
});
pinned.push(...versions.map((p) => ({ id: p.id, label: p.label })));
}
return [...aliases, ...pinned];
}
function catalogModels(tool, db) {
const out = new Map();
const prefix = tool.model.vendor && VENDOR_PREFIX[tool.model.vendor];
if (prefix) {
for (const p of pricedIds(db)) if (p.id.startsWith(prefix)) out.set(p.id, p);
}
const used = db
.prepare(
`SELECT DISTINCT t.model FROM token_usage t JOIN sessions s ON s.id = t.session_id
WHERE s.tool = ?`
)
.all(tool.id);
for (const { model } of used) {
if (aiTools.validModel(model) && !out.has(model)) out.set(model, { id: model, label: model });
}
return [...out.values()].sort((a, b) => b.id.localeCompare(a.id, undefined, { numeric: true }));
}
/**
* @param {string} id AI Tools catalog id
* @param {import("better-sqlite3").Database} db
* @returns {{flag: boolean, models: {id: string, label: string, hint?: string}[]}}
*/
function listModels(id, db) {
const tool = aiTools.get(id);
if (!tool) return { flag: false, models: [] };
if (tool.launcher === "codex") return { flag: true, models: codexModels() };
if (tool.launcher === "claude") return { flag: true, models: claudeModels(db) };
if (tool.model) return { flag: true, models: catalogModels(tool, db) };
return { flag: false, models: [] };
}
module.exports = { listModels };
+80 -2
View File
@@ -103,6 +103,8 @@ function validate(doc, source) {
launcher = doc.launcher;
}
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
const config = configFiles(doc.config, source);
if (!config) return null;
const fields = installFields(doc);
if (!fields) return null;
let prompt = null;
@@ -119,9 +121,20 @@ function validate(doc, source) {
prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000;
}
}
// `model`: how a run picks a model on this CLI. Only a flag name and a
// vendor (which pricing rows to offer as choices) — the value itself is
// the user's, validated by validModel() at spawn time.
let model = null;
if (doc.model !== undefined) {
const m = doc.model;
if (!m || typeof m.flag !== "string" || !/^--?[a-z][a-z0-9-]*$/.test(m.flag)) return null;
if (m.vendor !== undefined && !MODEL_VENDORS.has(m.vendor)) return null;
model = { flag: m.flag, vendor: m.vendor || null };
}
return {
id: doc.id,
label: doc.label.trim(),
model,
detect: doc.detect,
requires: doc.requires || [],
args: doc.args || [],
@@ -137,10 +150,49 @@ function validate(doc, source) {
launcher,
install: fields.install,
update: fields.update,
config,
source,
};
}
/** A name no config editor may open: logins, tokens, keys. */
const CONFIG_SECRET_RE =
/(auth|credential|token|secret|oauth|cookie|passw|api[-_]?key|\.env$|\.pem$|\.key$|id_rsa)/i;
const CONFIG_FORMATS = new Set(["json", "jsonc", "text"]);
/**
* `config`: the tool's own config files its Settings tab may edit — like
* `install`, only from a trusted file. Each is `~/`-relative, never climbs
* out with `..`, and never names a credential. Answers [] when absent, null
* when invalid.
*/
function configFiles(raw, source) {
if (raw === undefined) return [];
if (!TRUSTED.has(source) || !Array.isArray(raw) || raw.length > 8) return null;
const out = [];
for (const c of raw) {
const p = c && c.path;
if (typeof p !== "string" || !p.startsWith("~/") || p.includes("\0")) return null;
if (p.split("/").some((seg) => seg === "..") || CONFIG_SECRET_RE.test(path.basename(p)))
return null;
const format = c.format ?? (/\.jsonc$/.test(p) ? "jsonc" : /\.json$/.test(p) ? "json" : "text");
if (!CONFIG_FORMATS.has(format)) return null;
out.push({ path: p, format });
}
return out;
}
const MODEL_VENDORS = new Set(["claude", "openai", "google"]);
/**
* A model id that may follow a model flag as one argv element: a letter or
* digit first (never `-`, so it can not become a flag of its own), then the
* characters real ids use — `opus[1m]`, `openai/gpt-5.5`, `gemini-3.1-pro`.
*/
function validModel(m) {
return typeof m === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(m);
}
const RANK = { builtin: 0, remote: 1, custom: 2 };
/** `[dir, source]` pairs, lowest precedence first. */
@@ -249,12 +301,17 @@ const statePath = () => path.join(getDataDir(), "ai-tools.json");
function readState() {
try {
const s = JSON.parse(fs.readFileSync(statePath(), "utf8"));
const models = {};
if (s.models && typeof s.models === "object") {
for (const [id, m] of Object.entries(s.models)) if (validModel(m)) models[id] = m;
}
return {
disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [],
default: typeof s.default === "string" ? s.default : null,
models,
};
} catch {
return { disabled: [], default: null };
return { disabled: [], default: null, models: {} };
}
}
@@ -290,6 +347,18 @@ function setDefault(id) {
writeState({ ...readState(), default: id });
}
/** The model a new run of `id` starts with; null clears it (the CLI's own). */
function setDefaultModel(id, model) {
requireTool(id);
if (model !== null && !validModel(model)) {
throw makeErr("EBADMODEL", "not a valid model id");
}
const s = readState();
if (model === null) delete s.models[id];
else s.models[id] = model;
writeState(s);
}
function effectiveDefault() {
const s = readState();
const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id);
@@ -300,7 +369,8 @@ function effectiveDefault() {
/** The GET /api/ai-tools body — flags only, never the arg arrays. */
function view() {
const disabled = new Set(readState().disabled);
const state = readState();
const disabled = new Set(state.disabled);
return {
tools: list().map((t) => {
const d = detect(t.id);
@@ -318,6 +388,11 @@ function view() {
resume: !!t.resume,
bypass: !!(t.bypass || t.bypassEnv),
routerCapable: ROUTER_CAPABLE.has(t.id),
// claude/codex take a model through their own argv builders.
modelFlag: Boolean(t.model || t.launcher),
defaultModel: state.models[t.id] || null,
modelVendor: t.model?.vendor || null,
configFiles: t.config.length,
install: t.install,
update: t.update,
};
@@ -345,11 +420,14 @@ module.exports = {
isEnabled,
setEnabled,
setDefault,
setDefaultModel,
validModel,
effectiveDefault,
view,
validate,
which,
siteOf,
ROUTER_CAPABLE,
CONFIG_SECRET_RE,
__setCatalogDirs,
};
+144 -36
View File
@@ -1,19 +1,19 @@
/**
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
* models the dashboard has seen but has no rate row of its own
* for. Every model id in `token_usage` is reduced to its family (a
* `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix dropped); a family with no
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
* never updated, so a rate the user edited in Settings always wins. "Its own
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
* Opus 5 by the broader `claude-opus-5%` pattern.
*
* The list is fetched only when something is missing, at most once per
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken
* only from its own vendor (`anthropic` for claude-…, `openai` for gpt-…,
* o<n>…, codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id (`clinepass/…`, `Highest`) is left to
* the user.
* models, from LiteLLM's public price list fetched at most once a day. A row
* (`source = 'sync'`) is INSERTED for every family used in `token_usage` with
* no `<family>%` row of its own (a `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix
* dropped; "its own row" is the test — Opus 5.5 was once priced as Opus 5 by
* the broader `claude-opus-5%`) and for every current-generation chat model of
* the three vendors before anyone uses it (`isCurrent`). The sync's own rows
* follow a published rate change; a row from before the `source` column is
* adopted only while it still equals the list; a `'user'` row (edited in
* Settings) and a deleted pattern (`model_pricing_dismissed`) are never
* touched. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken only
* from its own vendor (`anthropic` for claude-…, `openai` for gpt-…, o<n>…,
* codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id
* (`clinepass/…`, `Highest`) is left to the user.
*
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
* modelled — Codex's context window stays under 272k and runs on the default
@@ -134,23 +134,84 @@ function missingFamilies(db) {
return [...out];
}
// ponytail: "current" is a fixed generation floor per vendor, not a release
// date — raise MIN_GEN when a generation retires. Anything older still gets a
// row the moment it is used (missingFamilies).
const MIN_GEN = { openai: 5, gemini: 3 };
const NOT_A_CODING_MODEL =
/(audio|search|tts|realtime|transcribe|image|computer-use|robotics|embedding|moderation|-exp-|latest$)/;
/**
* A price-list key worth a row before anyone uses it: a family id itself (no
* snapshot date), a chat model, not deprecated, of the vendor's current
* generation.
*/
/**
* Retired models (Claude 3.x, Opus 4/4.1, Sonnet 4): out of the defaults and
* pruned once from existing DBs by server/db.js. The sync prices one only when
* it is actually used — never as a "current" model added in advance.
*/
const RETIRED_PRICING_PATTERNS = [
"claude-opus-4-1%",
"claude-opus-4-2%",
"claude-sonnet-4-2%",
"claude-3-7-sonnet%",
"claude-3-5-sonnet%",
"claude-3-5-haiku%",
"claude-3-haiku%",
"claude-3-opus%",
];
// `claude-opus-4-2%` is Opus 4's dated ids (claude-opus-4-20250514): family claude-opus-4.
const RETIRED_FAMILIES = new Set(RETIRED_PRICING_PATTERNS.map((p) => p.replace(/(-2)?%$/, "")));
function isCurrent(key, e, today) {
const fam = familyOf(key);
if (fam !== key || !e || !["chat", "responses"].includes(e.mode)) return false;
if (typeof e.deprecation_date === "string" && e.deprecation_date < today) return false;
if (NOT_A_CODING_MODEL.test(fam) || RETIRED_FAMILIES.has(fam)) return false;
const vendor = vendorOf(fam);
if (vendor === "claude") return !/^claude-\d/.test(fam); // claude-3-5-sonnet naming = retired
const m = fam.match(/^(?:gpt|gemini)-(\d+(?:\.\d+)?)/);
return Boolean(m) && Number(m[1]) >= MIN_GEN[vendor];
}
const RATE_COLS = [
["input", "input_per_mtok"],
["output", "output_per_mtok"],
["read", "cache_read_per_mtok"],
["w5m", "cache_write_per_mtok"],
["w1h", "cache_write_1h_per_mtok"],
["fastIn", "fast_input_per_mtok"],
["fastOut", "fast_output_per_mtok"],
];
const sameRates = (row, db) => RATE_COLS.every(([k, col]) => Math.abs(row[k] - db[col]) < 1e-9);
async function defaultFetch() {
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
return res.json();
}
let appliedAt = null; // cached.at of the list last applied
/**
* One pass. Returns the patterns inserted. Never throws: pricing is a display
* concern and a failed fetch must not reach the server's startup path.
* One pass: fetch the list at most once a day, then (1) add a row for every
* used family with none and every current model of the three vendors, (2)
* keep the sync's own rows (`source = 'sync'`) at the published rate, and (3)
* adopt a row from before the `source` column while it still equals the list.
* A user's row (`'user'`) and a dismissed pattern are never touched. Returns
* the patterns inserted. Never throws: pricing is a display concern and a
* failed fetch must not reach the server's startup path.
*/
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
try {
const missing = missingFamilies(db);
if (missing.length === 0) return [];
if (!cached || now - cached.at > CACHE_MS) {
// A failed fetch is retried at the sync's own cadence, not on every
// caller (the Codex poll asks every 15 s while a session is active).
const stale = !cached || now - cached.at > CACHE_MS;
// The Codex poll calls this every 15 s while a session runs: nothing new
// to apply and nothing missing is the common answer, and it is free.
if (!stale && appliedAt === cached.at && missing.length === 0) return [];
if (stale) {
// A failed fetch is retried at the sync's own cadence, not on every caller.
if (now - failedAt < CHECK_MS) return [];
try {
cached = { at: now, list: await fetchList() };
@@ -159,29 +220,66 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
throw err;
}
}
const list = cached.list || {};
const today = new Date(now).toISOString().slice(0, 10);
const rows = new Map(
db
.prepare("SELECT * FROM model_pricing")
.all()
.map((r) => [r.model_pattern, r])
);
const dismissed = new Set(
db
.prepare("SELECT model_pattern FROM model_pricing_dismissed")
.all()
.map((r) => r.model_pattern)
);
const families = new Set(missing);
for (const [key, e] of Object.entries(list)) if (isCurrent(key, e, today)) families.add(key);
for (const pattern of rows.keys()) {
const fam = pattern.endsWith("%") ? pattern.slice(0, -1) : null;
if (fam && familyOf(fam) === fam) families.add(fam);
}
const insert = db.prepare(
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
fast_input_per_mtok, fast_output_per_mtok, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
);
const update = db.prepare(
`UPDATE model_pricing SET display_name = ?, input_per_mtok = ?, output_per_mtok = ?,
cache_read_per_mtok = ?, cache_write_per_mtok = ?, cache_write_1h_per_mtok = ?,
fast_input_per_mtok = ?, fast_output_per_mtok = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE model_pattern = ? AND source = 'sync'`
);
const adopt = db.prepare(
"UPDATE model_pricing SET source = 'sync' WHERE model_pattern = ? AND source IS NULL"
);
const added = [];
for (const fam of missing) {
const row = toRow(fam, cached.list?.[fam]);
if (!row) continue;
const { changes } = insert.run(
row.pattern,
row.name,
row.input,
row.output,
row.read,
row.w5m,
row.w1h,
row.fastIn,
row.fastOut
);
if (changes) added.push(row.pattern);
}
const changed = [];
db.transaction(() => {
for (const fam of families) {
const pattern = `${fam}%`;
if (dismissed.has(pattern)) continue;
const row = toRow(fam, list[fam]);
if (!row) continue;
const rates = [row.input, row.output, row.read, row.w5m, row.w1h, row.fastIn, row.fastOut];
const have = rows.get(pattern);
if (!have) {
if (insert.run(pattern, row.name, ...rates).changes) added.push(pattern);
} else if (have.source === "sync") {
if (!sameRates(row, have) && update.run(have.display_name, ...rates, pattern).changes) {
changed.push(pattern);
}
} else if (have.source == null && !have.intro_until && sameRates(row, have)) {
adopt.run(pattern);
}
}
})();
appliedAt = cached.at;
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
if (changed.length) console.log(`[pricing] updated rates for ${changed.join(", ")}`);
return added;
} catch (err) {
console.warn("[pricing] sync failed:", err.message);
@@ -202,20 +300,30 @@ function startPricingSync(db) {
function _resetCache() {
cached = null;
failedAt = -Infinity;
appliedAt = null;
}
/** Make the next pass re-apply the cached list (after a reset emptied the table). */
function invalidate() {
appliedAt = null;
}
/** Test seam: pretend the price list was just fetched. */
function _setCache(list, at = Date.now()) {
cached = { at, list };
appliedAt = null;
}
module.exports = {
RETIRED_PRICING_PATTERNS,
syncPricing,
invalidate,
syncEnabled,
startPricingSync,
familyOf,
displayName,
toRow,
isCurrent,
_resetCache,
_setCache,
};
+10 -2
View File
@@ -147,12 +147,15 @@ function buildCodexArgv({
/**
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
* ...bypass?, ...prompt]`. Every piece but the prompt comes from the catalog
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
* catalog declares a model flag). Every piece but the prompt and the
* validated model comes from the catalog
* file, and the prompt is one argv element — or, for `after-start`, nothing
* here: spawnRun pastes it into the pane once the CLI is up.
*/
function buildCatalogArgv(entry, bin, { resumeLast, bypass, initialPrompt }) {
function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
const argv = [bin, ...entry.args];
if (model && entry.model) argv.push(entry.model.flag, model);
if (resumeLast && entry.resume) argv.push(...entry.resume);
if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt;
@@ -239,6 +242,11 @@ function spawnRun(args) {
`permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}`
);
}
// A model is one argv element after a model flag; one starting with `-`
// would be read as a flag of its own.
if (model != null && model !== "" && !aiTools.validModel(model)) {
throw makeErr("EBADMODEL", "model is not a valid model id");
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}