7a88b74016
- 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>
734 lines
28 KiB
JavaScript
734 lines
28 KiB
JavaScript
/**
|
||
* @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;
|