fix(cost): per-model pricing so pi/Paperclip report real spend
The omni provider's models.json entries carried no 'cost' field, so pi's
calculateCost() returned 0 for every run and Paperclip cost events/budgets
all read $0.00 despite real spend.
- scripts/omni-pricing.mjs: pulls authoritative prices (OmniRoute /api/pricing
for deepseek/*, OpenRouter /api/v1/models for openrouter/*) and injects
{ cost: { input, output, cacheRead, cacheWrite } } USD per 1M tokens.
- Priced: deepseek-v4-flash, deepseek-v4-pro, gemini-3.1-flash-lite, gemini-2.5-flash.
- Must be re-run after every /omni sync, which rewrites models.json wholesale.
This commit is contained in:
10604
models.json
10604
models.json
File diff suppressed because it is too large
Load Diff
164
scripts/omni-pricing.mjs
Normal file
164
scripts/omni-pricing.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Inject per-model pricing into ~/.agents/models.json for the `omni` provider.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* `pi --list-models` / the omni extension's /omni sync regenerates models.json
|
||||
* from OmniRoute and writes entries WITHOUT a `cost` field. pi's calculateCost()
|
||||
* then reports $0.00 for every run, so Paperclip's cost events and budgets all
|
||||
* read zero even though real money is being spent.
|
||||
*
|
||||
* The fix is to add { cost: { input, output, cacheRead, cacheWrite } } (USD per
|
||||
* million tokens) to the model entries we actually use.
|
||||
*
|
||||
* Because /omni sync rewrites the whole file, this script must be re-run after
|
||||
* every sync. That is the whole reason it is a script and not a one-off edit.
|
||||
*
|
||||
* PRICE SOURCES (both authoritative, no guessing)
|
||||
* - deepseek/* -> OmniRoute /api/pricing, group "deepseek"
|
||||
* - openrouter/* -> OpenRouter /api/v1/models (strip the "openrouter/" prefix)
|
||||
*
|
||||
* Model ids that use routing prefixes (aug/, ds/, oc/, tllm/, opencode-go/) do
|
||||
* NOT map cleanly to pricing groups and are deliberately skipped — add them to
|
||||
* MANUAL below if you need them.
|
||||
*
|
||||
* USAGE
|
||||
* node omni-pricing.mjs --dry-run # show what would change
|
||||
* node omni-pricing.mjs # write models.json
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const MODELS_JSON = path.join(process.env.HOME, ".agents", "models.json");
|
||||
const OMNIROUTE_PRICING = "http://192.168.20.13:20128/api/pricing";
|
||||
const OPENROUTER_MODELS = "https://openrouter.ai/api/v1/models";
|
||||
|
||||
// Models we actually assign to workers. Keep this list short and deliberate.
|
||||
const TARGETS = [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"openrouter/google/gemini-3.1-flash-lite",
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
];
|
||||
|
||||
// Hand-priced entries: id -> {input, output, cacheRead, cacheWrite}
|
||||
const MANUAL = {};
|
||||
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
||||
function omniKey() {
|
||||
const cfg = JSON.parse(fs.readFileSync(MODELS_JSON, "utf8"));
|
||||
return cfg.providers?.omni?.apiKey ?? "";
|
||||
}
|
||||
|
||||
/** Fetch OmniRoute's own price table: { group: { model: {input,output,cached,cache_creation} } } */
|
||||
async function fetchOmniRoutePricing() {
|
||||
const res = await fetch(OMNIROUTE_PRICING, {
|
||||
headers: { Authorization: `Bearer ${omniKey()}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`OmniRoute pricing HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Fetch OpenRouter prices keyed by bare model id (no "openrouter/" prefix). */
|
||||
async function fetchOpenRouterPricing() {
|
||||
const res = await fetch(OPENROUTER_MODELS);
|
||||
if (!res.ok) throw new Error(`OpenRouter models HTTP ${res.status}`);
|
||||
const { data } = await res.json();
|
||||
const out = {};
|
||||
for (const m of data) {
|
||||
const p = m.pricing ?? {};
|
||||
out[m.id] = {
|
||||
input: Number(p.prompt ?? 0) * 1e6,
|
||||
output: Number(p.completion ?? 0) * 1e6,
|
||||
cacheRead: Number(p.input_cache_read ?? 0) * 1e6,
|
||||
cacheWrite: Number(p.input_cache_write ?? 0) * 1e6,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const r6 = (n) => Math.round(Number(n) * 1e6) / 1e6;
|
||||
|
||||
/** Resolve a pi model id to a {input,output,cacheRead,cacheWrite} price, or null. */
|
||||
function resolvePrice(id, omniPricing, orPricing) {
|
||||
if (MANUAL[id]) return MANUAL[id];
|
||||
|
||||
// deepseek/<name> -> OmniRoute group "deepseek"
|
||||
if (id.startsWith("deepseek/")) {
|
||||
const name = id.slice("deepseek/".length);
|
||||
const row = omniPricing?.deepseek?.[name];
|
||||
if (row) {
|
||||
return {
|
||||
input: r6(row.input),
|
||||
output: r6(row.output),
|
||||
cacheRead: r6(row.cached ?? 0),
|
||||
cacheWrite: r6(row.cache_creation ?? 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// openrouter/<bare-id> -> OpenRouter catalogue
|
||||
if (id.startsWith("openrouter/")) {
|
||||
const bare = id.slice("openrouter/".length);
|
||||
if (orPricing[bare]) {
|
||||
const p = orPricing[bare];
|
||||
return { input: r6(p.input), output: r6(p.output), cacheRead: r6(p.cacheRead), cacheWrite: r6(p.cacheWrite) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const cfg = JSON.parse(fs.readFileSync(MODELS_JSON, "utf8"));
|
||||
const models = cfg.providers?.omni?.models;
|
||||
if (!Array.isArray(models)) {
|
||||
console.error("models.json: providers.omni.models is not an array — aborting");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [omniPricing, orPricing] = await Promise.all([
|
||||
fetchOmniRoutePricing(),
|
||||
fetchOpenRouterPricing(),
|
||||
]);
|
||||
|
||||
const byId = new Map(models.filter((m) => m && m.id).map((m) => [m.id, m]));
|
||||
let changed = 0;
|
||||
const missing = [];
|
||||
|
||||
for (const id of TARGETS) {
|
||||
const entry = byId.get(id);
|
||||
if (!entry) {
|
||||
missing.push(id);
|
||||
continue;
|
||||
}
|
||||
const price = resolvePrice(id, omniPricing, orPricing);
|
||||
if (!price) {
|
||||
missing.push(id);
|
||||
continue;
|
||||
}
|
||||
const current = JSON.stringify(entry.cost ?? null);
|
||||
const next = JSON.stringify(price);
|
||||
if (current !== next) {
|
||||
entry.cost = price;
|
||||
changed++;
|
||||
console.log(
|
||||
`${dryRun ? "[dry-run] " : ""}${id} -> in $${price.input} out $${price.output} cacheR $${price.cacheRead} cacheW $${price.cacheWrite}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`${id} -> already priced, unchanged`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length) console.log(`\nnot found / no price (skipped): ${missing.join(", ")}`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\ndry-run: ${changed} entr(y/ies) would change`);
|
||||
} else if (changed) {
|
||||
fs.writeFileSync(MODELS_JSON, JSON.stringify(cfg, null, 2) + "\n");
|
||||
console.log(`\nwrote ${MODELS_JSON} (${changed} changed)`);
|
||||
} else {
|
||||
console.log("\nnothing to do");
|
||||
}
|
||||
Reference in New Issue
Block a user