158783d5d5
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL.
143 lines
5.3 KiB
JavaScript
143 lines
5.3 KiB
JavaScript
/**
|
|
* @file Automatic pricing for Claude 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` 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.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const PRICE_LIST_URL =
|
|
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
const CHECK_MS = 15 * 60_000;
|
|
const CACHE_MS = 24 * 60 * 60_000;
|
|
/** A per-MTok rate above this is a parse error, not a price. */
|
|
const MAX_PER_MTOK = 1000;
|
|
|
|
let cached = null; // { at, list }
|
|
|
|
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
|
|
function familyOf(model) {
|
|
if (typeof model !== "string") return null;
|
|
const fam = model.replace(/-\d{8}$/, "");
|
|
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
|
}
|
|
|
|
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
|
|
function displayName(fam) {
|
|
const [, ...parts] = fam.split("-");
|
|
const words = [];
|
|
const nums = [];
|
|
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
|
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
|
}
|
|
|
|
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
|
function toRow(fam, e) {
|
|
if (!e || e.litellm_provider !== "anthropic") return null;
|
|
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
|
const input = per(e.input_cost_per_token);
|
|
const output = per(e.output_cost_per_token);
|
|
// Anthropic's published multipliers stand in for a field the list omits.
|
|
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
|
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
|
|
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
|
|
const rates = [input, output, read, w5m, w1h];
|
|
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
|
if (input === 0 && output === 0) return null;
|
|
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
|
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
|
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
|
const r = (n) => Math.round(n * 1e6) / 1e6;
|
|
return {
|
|
pattern: `${fam}%`,
|
|
name: displayName(fam),
|
|
input: r(input),
|
|
output: r(output),
|
|
read: r(read),
|
|
w5m: r(w5m),
|
|
w1h: r(w1h),
|
|
fastIn: fastOk ? r(input * fast) : 0,
|
|
fastOut: fastOk ? r(output * fast) : 0,
|
|
};
|
|
}
|
|
|
|
/** Families used in token_usage that have no `<family>%` row of their own. */
|
|
function missingFamilies(db) {
|
|
const have = new Set(
|
|
db
|
|
.prepare("SELECT model_pattern FROM model_pricing")
|
|
.all()
|
|
.map((r) => r.model_pattern)
|
|
);
|
|
const out = new Set();
|
|
for (const { model } of db.prepare("SELECT DISTINCT model FROM token_usage").all()) {
|
|
const fam = familyOf(model);
|
|
if (fam && !have.has(`${fam}%`)) out.add(fam);
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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) cached = { at: now, list: await fetchList() };
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
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);
|
|
}
|
|
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
|
|
return added;
|
|
} catch (err) {
|
|
console.warn("[pricing] sync failed:", err.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function startPricingSync(db) {
|
|
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
|
|
void syncPricing(db);
|
|
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
|
}
|
|
|
|
function _resetCache() {
|
|
cached = null;
|
|
}
|
|
|
|
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
|