Files
AgentHub/server/routes/pricing.js
T
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

734 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Express router for managing pricing rules and calculating costs based on token usage. It provides endpoints to list, create/update, and delete pricing rules, as well as calculate total costs across all sessions or for a specific session. The cost calculation matches token usage against the most specific applicable pricing rule based on model patterns.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { stmts, db } = require("../db");
const {
parseScope,
parseTool,
BAD_TOOL,
parseRange,
inRange,
BAD_RANGE,
} = require("../lib/source-filter");
const { quotas } = require("../lib/quota");
const {
WEB_SEARCH_PER_1K_SEARCHES,
CODE_EXEC_PER_HOUR,
CODE_EXEC_FREE_HOURS,
estimateCodeExecHours,
DATA_RESIDENCY_US_MULTIPLIER,
BATCH_DISCOUNT_MULTIPLIER,
} = require("../lib/pricing-constants");
const router = Router();
const round4 = (n) => Math.round(n * 10000) / 10000;
/**
* Resolve the effective per-MTok rates for a token bucket, applying the pricing
* modifiers carried on the bucket (fast mode, US data residency, Batch API).
* - Fast mode: premium input/output rates; cache rates scale with the fast
* input base (the standard caching multipliers ride on top of fast pricing).
* - Data residency "us": 1.1x across every category.
* - Batch tier (and OpenAI's flex tier): 50% off across every category.
* Older buckets default to speed=standard / geo=global / tier=standard, so they
* resolve to exactly the standard rates — historical sessions price unchanged.
*/
function ratesForBucket(rule, row, asOf) {
const r = rule || {};
// Time-limited introductory rates. Prefer the usage row's own date (so
// historical usage keeps the rate it was billed at and future usage picks up
// the standard rate), else the caller-provided asOf, else today. Dates are
// compared as YYYY-MM-DD strings (both intro_until and the daily date use that
// shape), so slice any full ISO timestamp to its day.
const day = String(row.date || asOf || new Date().toISOString()).slice(0, 10);
const useIntro = !!r.intro_until && day <= r.intro_until;
const pick = (introVal, stdVal) => (useIntro && (introVal || 0) > 0 ? introVal : stdVal || 0);
let rIn = pick(r.intro_input_per_mtok, r.input_per_mtok);
let rOut = pick(r.intro_output_per_mtok, r.output_per_mtok);
let rRead = pick(r.intro_cache_read_per_mtok, r.cache_read_per_mtok);
let r5m = pick(r.intro_cache_write_per_mtok, r.cache_write_per_mtok);
let r1h = pick(r.intro_cache_write_1h_per_mtok, r.cache_write_1h_per_mtok);
if (row.speed === "fast" && (r.fast_input_per_mtok || 0) > 0) {
const baseIn = r.input_per_mtok || 0;
const factor = baseIn > 0 ? r.fast_input_per_mtok / baseIn : 1;
rIn = r.fast_input_per_mtok;
rOut = (r.fast_output_per_mtok || 0) > 0 ? r.fast_output_per_mtok : rOut * factor;
rRead *= factor;
r5m *= factor;
r1h *= factor;
}
if (row.inference_geo === "us") {
const m = DATA_RESIDENCY_US_MULTIPLIER;
rIn *= m;
rOut *= m;
rRead *= m;
r5m *= m;
r1h *= m;
}
if (row.service_tier === "batch" || row.service_tier === "flex") {
const m = BATCH_DISCOUNT_MULTIPLIER;
rIn *= m;
rOut *= m;
rRead *= m;
r5m *= m;
r1h *= m;
}
return { rIn, rOut, rRead, r5m, r1h };
}
const LONG_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
].map((c) => `long_${c}`);
/**
* What the long-context part of a bucket adds on top of pricing ALL of it at
* the bucket's rates: each long subset (`long_<col>`, capped at its total) at
* (factor - 1) x its rate. The input-side factor is long_input / input and
* also scales the cache rates, as fast mode's does; output has its own. A rule
* with no long rates adds nothing.
*/
function longContextExtra(rule, row, { rIn, rOut, rRead, r5m, r1h }) {
const r = rule || {};
const factor = (longRate, base) => (longRate > 0 && base > 0 ? longRate / base : 1);
const fIn = factor(r.long_input_per_mtok, r.input_per_mtok);
const fOut = factor(r.long_output_per_mtok, r.output_per_mtok);
if (fIn === 1 && fOut === 1) return 0;
const long = (col) => Math.min(row[`long_${col}`] || 0, row[col] || 0);
const l1h = long("cache_write_1h_tokens");
const l5m = Math.max(0, long("cache_write_tokens") - l1h);
const inSide =
long("input_tokens") * rIn + long("cache_read_tokens") * rRead + l5m * r5m + l1h * r1h;
return ((fIn - 1) * inSide + (fOut - 1) * long("output_tokens") * rOut) / 1e6;
}
// Calculate cost for a set of token buckets against pricing rules. Each bucket
// is (model, speed, inference_geo, service_tier) with token counts plus the 1h
// cache-write split and server-tool request counts. Cost = token cost (rate-
// modified) + web-search surcharge ($10/1k) + estimated code-execution time
// (free when used with web search/fetch; org free-hours allowance applied once).
function calculateCost(tokenRows, pricingRules, asOf) {
const sortedRules = [...pricingRules].sort(
(a, b) => b.model_pattern.length - a.model_pattern.length
);
let tokenCost = 0;
let webSearchCost = 0;
let codeExecHours = 0;
// Breakdown is aggregated per (model, speed, geo, tier) tuple, not per input
// row. This lets callers feed date-split rows (e.g. the daily-usage query,
// one row per date × model) so each row is priced at its own date's rate,
// while the breakdown still collapses to one entry per model. For callers that
// already pass one row per tuple (aggregate total, per-session), it's a no-op.
const breakdownMap = new Map();
// Track buckets that matched NO pricing rule. Their cost is $0, which would
// silently under-report the true total — surface them so the number is honest
// and the user knows to add a rule (e.g. a brand-new model id).
const unpriced = new Map();
for (const row of tokenRows) {
const rule = sortedRules.find((p) => {
const pattern = p.model_pattern.replace(/%/g, ".*");
return new RegExp("^" + pattern + "$").test(row.model);
});
if (!rule) {
const u = unpriced.get(row.model) || {
model: row.model,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
};
u.input_tokens += row.input_tokens || 0;
u.output_tokens += row.output_tokens || 0;
u.cache_read_tokens += row.cache_read_tokens || 0;
u.cache_write_tokens += row.cache_write_tokens || 0;
unpriced.set(row.model, u);
}
const rates = ratesForBucket(rule, row, asOf);
const { rIn, rOut, rRead, r5m, r1h } = rates;
const cw1h = row.cache_write_1h_tokens || 0;
const cw5m = Math.max(0, (row.cache_write_tokens || 0) - cw1h);
const tCost =
(row.input_tokens / 1e6) * rIn +
(row.output_tokens / 1e6) * rOut +
(row.cache_read_tokens / 1e6) * rRead +
(cw5m / 1e6) * r5m +
(cw1h / 1e6) * r1h +
longContextExtra(rule, row, rates);
const wsCost = ((row.web_search_requests || 0) / 1000) * WEB_SEARCH_PER_1K_SEARCHES;
const ceHours = estimateCodeExecHours(
row.code_execution_requests,
row.web_search_requests,
row.web_fetch_requests
);
tokenCost += tCost;
webSearchCost += wsCost;
codeExecHours += ceHours;
const key = `${row.model}|${row.speed || "standard"}|${row.inference_geo || "global"}|${row.service_tier || "standard"}`;
const agg = breakdownMap.get(key) || {
model: row.model,
speed: row.speed || "standard",
inference_geo: row.inference_geo || "global",
service_tier: row.service_tier || "standard",
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cache_write_1h_tokens: 0,
web_search_requests: 0,
web_fetch_requests: 0,
code_execution_requests: 0,
...Object.fromEntries(LONG_COLS.map((c) => [c, 0])),
_cost: 0,
matched_rule: rule?.model_pattern || null,
};
agg.input_tokens += row.input_tokens || 0;
agg.output_tokens += row.output_tokens || 0;
agg.cache_read_tokens += row.cache_read_tokens || 0;
agg.cache_write_tokens += row.cache_write_tokens || 0;
agg.cache_write_1h_tokens += cw1h;
agg.web_search_requests += row.web_search_requests || 0;
agg.web_fetch_requests += row.web_fetch_requests || 0;
agg.code_execution_requests += row.code_execution_requests || 0;
for (const c of LONG_COLS) agg[c] += row[c] || 0;
agg._cost += tCost + wsCost;
breakdownMap.set(key, agg);
}
const breakdown = [...breakdownMap.values()].map(({ _cost, ...b }) => ({
...b,
cost: round4(_cost),
}));
// Code execution is billed by container-time, estimated at the 5-minute
// minimum per request. Apply the org free-hours allowance once, then charge
// the remainder — so normal usage (well under the allowance) costs $0.
const chargedHours = Math.max(0, codeExecHours - CODE_EXEC_FREE_HOURS);
const codeExecCost = chargedHours * CODE_EXEC_PER_HOUR;
const total = tokenCost + webSearchCost + codeExecCost;
return {
total_cost: round4(total),
breakdown,
feature_costs: {
web_search_cost: round4(webSearchCost),
web_fetch_cost: 0,
code_execution_cost: round4(codeExecCost),
code_execution_hours_estimated: round4(codeExecHours),
code_execution_free_hours: CODE_EXEC_FREE_HOURS,
},
// Models with usage but no matching pricing rule (cost not counted).
unpriced_models: [...unpriced.values()],
};
}
function calculateDailyCosts(dailyTokenRows, pricingRules) {
const rowsByDate = new Map();
for (const row of dailyTokenRows) {
const rows = rowsByDate.get(row.date) || [];
rows.push({
model: row.model,
speed: row.speed,
inference_geo: row.inference_geo,
service_tier: row.service_tier,
input_tokens: row.input_tokens,
output_tokens: row.output_tokens,
cache_read_tokens: row.cache_read_tokens,
cache_write_tokens: row.cache_write_tokens,
cache_write_1h_tokens: row.cache_write_1h_tokens,
web_search_requests: row.web_search_requests,
web_fetch_requests: row.web_fetch_requests,
code_execution_requests: row.code_execution_requests,
...Object.fromEntries(LONG_COLS.map((c) => [c, row[c]])),
});
rowsByDate.set(row.date, rows);
}
return [...rowsByDate.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, rows]) => ({ date, cost: calculateCost(rows, pricingRules, date).total_cost }));
}
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
...LONG_COLS,
];
const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
/**
* Token rows per (local date, bucket) for the daily cost split. A bucket's
* total (`token_usage`) is booked to the local days its `token_usage_hourly`
* rows fall on; whatever the total holds beyond them (sessions recorded before
* that table, sub-agent files, Codex/Antigravity) goes to the session's start
* date, as it always did. Hours above the total (never expected) are scaled
* down to it, so the days always add up to the total. Each row also carries
* its session's `tool`, for the per-tool split.
*/
function dailyTokenRows(tzModifier, whereClause, params) {
return bookedTokenRows(
{ total: "DATE(s.started_at, ?)", hour: "DATE(h.hour || ':00:00', ?)", args: [tzModifier] },
whereClause,
params
);
}
/**
* The same booking per UTC hour (`YYYY-MM-DDTHH` in `date`) and per session
* (`session_id`): what the account pool sums over a run's own hours. The rest
* of a total goes to the session's start hour.
*/
function hourlyTokenRows(whereClause, params) {
return bookedTokenRows(
{ total: "substr(s.started_at, 1, 13)", hour: "h.hour", args: [] },
whereClause,
params,
{ bySession: true }
);
}
/** `slot.total`/`slot.hour` name the period of a total and of an hourly row (`slot.args` bound once each). */
function bookedTokenRows(slot, whereClause, params, { bySession = false } = {}) {
const totals = db
.prepare(
`SELECT tu.session_id, ${slot.total} as date, s.tool,
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
tu.input_tokens + tu.baseline_input as input_tokens,
tu.output_tokens + tu.baseline_output as output_tokens,
tu.cache_read_tokens + tu.baseline_cache_read as cache_read_tokens,
tu.cache_write_tokens + tu.baseline_cache_write as cache_write_tokens,
tu.cache_write_1h_tokens + tu.baseline_cache_write_1h as cache_write_1h_tokens,
tu.web_search_requests + tu.baseline_web_search as web_search_requests,
tu.web_fetch_requests + tu.baseline_web_fetch as web_fetch_requests,
tu.code_execution_requests + tu.baseline_code_execution as code_execution_requests,
${LONG_COLS.map((c) => `tu.${c}`).join(", ")}
FROM token_usage tu
JOIN sessions s ON s.id = tu.session_id
${whereClause}`
)
.all(...slot.args, ...params);
const hourly = db
.prepare(
`SELECT h.session_id, ${slot.hour} as date,
${BUCKET_COLS.map((c) => `h.${c}`).join(", ")},
${USAGE_COLS.map((c) => `SUM(h.${c}) as ${c}`).join(", ")}
FROM token_usage_hourly h
JOIN sessions s ON s.id = h.session_id
${whereClause}
GROUP BY h.session_id, 2, ${BUCKET_COLS.map((c) => `h.${c}`).join(", ")}`
)
.all(...slot.args, ...params);
const keyOf = (r) => [r.session_id, ...BUCKET_COLS.map((c) => r[c])].join("\u0001");
const hoursByBucket = new Map();
for (const h of hourly) {
const k = keyOf(h);
if (!hoursByBucket.has(k)) hoursByBucket.set(k, []);
hoursByBucket.get(k).push(h);
}
const out = new Map();
const add = (date, bucket, values) => {
const k = [
date,
bucket.tool,
...(bySession ? [bucket.session_id] : []),
...BUCKET_COLS.map((c) => bucket[c]),
].join("\u0001");
let row = out.get(k);
if (!row) {
row = {
date,
tool: bucket.tool,
...(bySession ? { session_id: bucket.session_id } : {}),
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
};
for (const c of USAGE_COLS) row[c] = 0;
out.set(k, row);
}
for (const c of USAGE_COLS) row[c] += values[c];
};
for (const t of totals) {
const days = hoursByBucket.get(keyOf(t)) || [];
const scale = {};
const rest = {};
for (const c of USAGE_COLS) {
const spent = days.reduce((n, d) => n + d[c], 0);
scale[c] = spent > t[c] ? t[c] / spent : 1;
rest[c] = Math.max(t[c] - spent, 0);
}
for (const d of days) {
add(d.date, t, Object.fromEntries(USAGE_COLS.map((c) => [c, d[c] * scale[c]])));
}
if (USAGE_COLS.some((c) => rest[c] > 0)) add(t.date, t, rest);
}
return [...out.values()];
}
// GET /api/pricing - List all pricing rules
router.get("/", (_req, res) => {
const rules = stmts.listPricing.all();
res.json({ pricing: rules });
});
// PUT /api/pricing - Create or update a pricing rule
router.put("/", (req, res) => {
const {
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,
// Time-limited introductory rates (all optional). A caller that omits every
// intro field leaves any existing promo untouched (see introProvided below).
intro_input_per_mtok,
intro_output_per_mtok,
intro_cache_read_per_mtok,
intro_cache_write_per_mtok,
intro_cache_write_1h_per_mtok,
intro_until,
} = req.body;
if (!model_pattern || !display_name) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "model_pattern and display_name are required" },
});
}
// Every rate field must be a non-negative finite number when present. A
// typo'd value (Number("abc") → NaN) or a negative rate would otherwise be
// written straight into model_pricing and silently corrupt every downstream
// cost calculation until someone noticed and fixed the row by hand.
const RATE_FIELDS = [
"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",
"intro_input_per_mtok",
"intro_output_per_mtok",
"intro_cache_read_per_mtok",
"intro_cache_write_per_mtok",
"intro_cache_write_1h_per_mtok",
];
for (const field of RATE_FIELDS) {
const raw = req.body[field];
if (raw === undefined || raw === null || raw === "") continue;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: `${field} must be a non-negative number` },
});
}
}
// Absent/empty rates default to 0; numeric strings are coerced so a rate can
// never be bound into the DB as text.
const num = (v) => (v === undefined || v === null || v === "" ? 0 : Number(v));
// Only touch the intro columns when the caller actually sent at least one
// intro field. This keeps the endpoint backward-compatible: existing clients
// that PUT just the standard rates never clobber a promo, while the Settings
// UI (which always sends the full intro block) is authoritative for it.
const introKeys = [
"intro_input_per_mtok",
"intro_output_per_mtok",
"intro_cache_read_per_mtok",
"intro_cache_write_per_mtok",
"intro_cache_write_1h_per_mtok",
"intro_until",
];
const introProvided = introKeys.some((k) => req.body[k] !== undefined);
// Normalize / validate intro_until: an empty value clears the promo (NULL);
// a present value must be a YYYY-MM-DD date so the date-string comparisons in
// ratesForBucket stay correct.
let normalizedIntroUntil = null;
if (introProvided) {
const raw = typeof intro_until === "string" ? intro_until.trim() : intro_until;
if (raw) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "intro_until must be a YYYY-MM-DD date" },
});
}
normalizedIntroUntil = raw;
}
}
const writeRule = db.transaction(() => {
stmts.upsertPricing.run(
model_pattern,
display_name,
num(input_per_mtok),
num(output_per_mtok),
num(cache_read_per_mtok),
num(cache_write_per_mtok),
num(cache_write_1h_per_mtok),
num(fast_input_per_mtok),
num(fast_output_per_mtok)
);
if (introProvided) {
// A cleared promo (no date) zeroes the intro rates too so a stale value
// can't resurface if a date is re-added later without re-entering rates.
const keepRates = !!normalizedIntroUntil;
stmts.setIntroPricing.run(
keepRates ? num(intro_input_per_mtok) : 0,
keepRates ? num(intro_output_per_mtok) : 0,
keepRates ? num(intro_cache_read_per_mtok) : 0,
keepRates ? num(intro_cache_write_per_mtok) : 0,
keepRates ? num(intro_cache_write_1h_per_mtok) : 0,
normalizedIntroUntil,
model_pattern
);
}
});
writeRule();
const rule = stmts.getPricing.get(model_pattern);
res.json({ pricing: rule });
});
// DELETE /api/pricing/:pattern - Delete a pricing rule
router.delete("/:pattern", (req, res) => {
// Express has already percent-decoded route params, so a client that
// properly encodeURIComponent()s a pattern like "claude-opus-4-6%" hands us
// the raw "%" here — a second decodeURIComponent() then throws URIError
// (malformed escape) and the route 500s. Try the legacy double-decode for
// backward compatibility, but fall back to the already-decoded value.
let pattern;
try {
pattern = decodeURIComponent(req.params.pattern);
} catch {
pattern = req.params.pattern;
}
const existing = stmts.getPricing.get(pattern);
if (!existing) {
return res
.status(404)
.json({ error: { code: "NOT_FOUND", message: "Pricing rule not found" } });
}
// Remembered so the automatic sync does not add the row straight back.
db.transaction(() => {
stmts.deletePricing.run(pattern);
stmts.dismissPricing.run(pattern);
})();
res.json({ ok: true });
});
/**
* The `WHERE` over `token_usage tu JOIN sessions s` for a session predicate
* (`parseScope`'s `{where, params}`, or null), a workspace id (the sessions it
* owns — the rule of GET /api/sessions?lane=) and `extra` clauses on `s`.
*/
function costWhere({ scope = null, lane, extra = [] } = {}) {
const params = scope ? [...scope.params] : [];
const where = scope ? [`s.id IN (SELECT id FROM sessions WHERE ${scope.where})`] : [];
if (lane !== undefined && lane !== null) {
where.push(
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
);
params.push(Number(lane), Number(lane));
}
for (const e of extra) {
where.push(e.clause);
params.push(...e.params);
}
return { whereClause: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
}
/**
* `costWhere` for a cost request (`?sources=`, `?tool=`, `?lane=`). Null when
* the request is malformed (the 400 has already been sent).
*/
function costScope(req, res) {
const lane = req.query.lane;
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
res.status(400).json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
return null;
}
if (parseTool(req) === false) {
res.status(400).json(BAD_TOOL);
return null;
}
return costWhere({ scope: parseScope(req), lane });
}
/**
* Cost per AI tool (`sessions.tool`), most expensive first. Each tool is priced
* on its own rows, so the code-execution free allowance applies per tool and
* the parts can sum to slightly less than `total_cost` when it is exceeded.
*/
function costByTool(dailyTokens, rules) {
const byTool = new Map();
for (const r of dailyTokens) {
const t = r.tool || "claude";
if (!byTool.has(t)) byTool.set(t, []);
byTool.get(t).push(r);
}
return [...byTool.entries()]
.map(([tool, rows]) => ({ tool, cost: calculateCost(rows, rules).total_cost }))
.sort((a, b) => b.cost - a.cost);
}
/**
* The cost response for a `costWhere` result and a date range (`parseRange`,
* or null). The range narrows the total, breakdown and `by_tool` to the local
* days in it; `daily_costs` stays the whole series, so the trend charts and
* the spend pace keep their own windows. Also used by scheduled reports.
*/
function computeCost(
{ whereClause = "", params = [] } = {},
range = null,
tzModifier = "+0 minutes"
) {
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
const inside = range ? dailyTokens.filter((r) => inRange(range, r.date)) : dailyTokens;
const rules = stmts.listPricing.all();
// Price the date-split rows so each day's usage bills at the rate effective on
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
// Coverage equals the undated aggregate — token_usage cascades with sessions,
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
const result = calculateCost(inside, rules);
const daily_costs = calculateDailyCosts(dailyTokens, rules);
return { ...result, daily_costs, by_tool: costByTool(inside, rules) };
}
// GET /api/pricing/cost - Get total cost across all sessions.
// Honors the global data-scope — `?sources=<csv>` (origin machines, see
// server/lib/source-filter.js) and `?tool=<id>` — matching the sessions / stats
// / analytics endpoints so the Dashboard "total cost" tracks the selector, plus
// `?lane=<id>` (the sessions that workspace owns), `?from=&to=` (local
// dates, see computeCost) and `?date=` (one local day, its `daily_costs` too —
// the status bar's Today). `by_tool` splits the total.
router.get("/cost", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
const date = req.query.date;
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
}
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
const scope = costScope(req, res);
if (!scope) return;
// `date=`: only that local day (the status bar's Today and its per-model list).
if (date !== undefined) {
const day = computeCost(scope, { from: date, to: date }, tzModifier);
return res.json({ ...day, daily_costs: day.daily_costs.filter((d) => d.date === date) });
}
res.json(computeCost(scope, range, tzModifier));
});
// GET /api/pricing/quota - plan quota spent per AI tool (server/lib/quota.js lists them):
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
// Server mode with S3: a non-admin gets their own Linux account's quota only.
router.get("/quota", async (req, res) => {
const force = req.query.force === "1";
// S3: a developer's or analyst's plan is their OWN Linux account's, read
// from its home — never the host's; no provisioned account, no rows.
if (req.user && req.user.role !== "admin" && require("../lib/linux-accounts").s3Enabled()) {
let acct = null;
try {
acct = require("../lib/run-as").accountForUser(req.user.username);
} catch {
/* EMEMBERGONE: nothing of theirs to read */
}
if (!acct) return res.json({ tools: [], checkedAt: new Date().toISOString() });
return res.json(await quotas({ force, home: acct.home }));
}
const out = await quotas({ force });
// Server mode: a member sees how full the plan is, not whose accounts.
if (require("../lib/scope").memberId(req) != null && Array.isArray(out.tools)) {
out.tools = out.tools.map((t) => ({ ...t, account: null, label: t.id }));
}
res.json(out);
});
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
router.get("/cost/:sessionId", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
const tokenRows = stmts.getTokensBySession.all(req.params.sessionId);
const rules = stmts.listPricing.all();
const started = db
.prepare("SELECT DATE(started_at, ?) as date FROM sessions WHERE id = ?")
.get(tzModifier, req.params.sessionId);
// Price the session as of its start date so a session that ran during a promo
// window keeps that rate (e.g. Sonnet 5 intro through 2026-08-31).
const result = calculateCost(tokenRows, rules, started?.date);
const daily_costs = started ? [{ date: started.date, cost: result.total_cost }] : [];
res.json({ ...result, daily_costs });
});
/**
* Compute a single agent's own cost from the token buckets stashed in its
* metadata by the importer (agent.metadata.tokens). Returns 0 when the agent has
* no per-agent usage recorded (e.g. main agents — whose cost is the session
* total — compaction pseudo-agents, or live subagents not yet backfilled from
* their transcript). Priced with the agent's start date so a promo/standard
* cutover is respected, exactly like session cost.
*/
function agentOwnCost(agent, pricingRules) {
if (!agent || !agent.metadata) return 0;
let meta;
try {
meta = JSON.parse(agent.metadata);
} catch {
return 0;
}
const rows = Array.isArray(meta.tokens) ? meta.tokens : null;
if (!rows || rows.length === 0) return 0;
const asOf = agent.started_at ? String(agent.started_at).slice(0, 10) : undefined;
return calculateCost(rows, pricingRules, asOf).total_cost;
}
/**
* Return a shallow copy of each agent row with a computed `cost` field — the
* agent's OWN cost (see agentOwnCost). Pricing rules are read once for the whole
* batch. Used by the agent-list endpoints so subagent cards can show their real
* cost instead of the session total.
*/
function attachAgentCosts(agents) {
if (!Array.isArray(agents) || agents.length === 0) return agents;
const rules = stmts.listPricing.all();
return agents.map((a) => ({ ...a, cost: agentOwnCost(a, rules) }));
}
module.exports = router;
module.exports.calculateCost = calculateCost;
module.exports.dailyTokenRows = dailyTokenRows;
module.exports.hourlyTokenRows = hourlyTokenRows;
module.exports.costWhere = costWhere;
module.exports.computeCost = computeCost;
module.exports.agentOwnCost = agentOwnCost;
module.exports.attachAgentCosts = attachAgentCosts;