Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions admin/src/costs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ export const COST_CLASSES = ["metered", "plan", "zero-rated", "estimated", "seed
const DAY_MS = 24 * 60 * 60 * 1000;
const HOUR_MS = 60 * 60 * 1000;

/** The spend windows every costs surface offers, in `t`-cycle order. */
export const COSTS_WINDOWS = ["7d", "30d", "mtd"];

/**
* A window's inclusive start: 7d/30d count back from now; mtd is the start of the current UTC month.
* This lives HERE, beside the fold, because proration denominates on the requested window: the scan
* cutoff and the fold's `sinceMs` must be the same instant, and two implementations of "where does
* mtd start" (the drift this module's dayKey import already guards against at day grain) once let the
* command and the view disagree by a whole month edge.
*/
export function costsSinceMs(windowKey, nowMs) {
if (windowKey === "7d") return nowMs - 7 * DAY_MS;
if (windowKey === "30d") return nowMs - 30 * DAY_MS;
const now = new Date(nowMs);
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
}

/** The ledger row fields that form a reprice quad -- the full cache split, exactly as recorded. */
const QUAD_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cacheWrite1h"];

Expand Down Expand Up @@ -218,7 +235,7 @@ function repriceRows(rows, counterfactual, pricing, { extraExcluded = 0 } = {})
* read-model the costs view renders. Everything below is derived per call and thrown away -- nothing
* classified here is ever written anywhere.
*/
export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = null }) {
export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = null, sinceMs = null }) {
const subs = Array.isArray(subscriptions) ? subscriptions : [];
const runs = [];
for (const record of Array.isArray(records) ? records : []) {
Expand All @@ -229,13 +246,25 @@ export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = nu
}
runs.sort((a, b) => a.at - b.at);

const fromMs = runs.length > 0 ? runs[0].at : nowMs;
// The window the caller ASKED about versus the window the records happen to span. Proration must
// denominate on the requested window (`sinceMs`, the same instant the caller cut the scan at):
// deriving days from the first observed run understates plan cost on any sparse window -- two runs
// yesterday against a month-to-date question made every verdict read SAVING -- so that derivation
// survives only for callers that fold an arbitrary record set with no window to ask about.
const firstRunMs = runs.length > 0 ? runs[0].at : null;
const requested = Number.isFinite(sinceMs);
const fromMs = requested ? Math.min(sinceMs, nowMs) : (firstRunMs ?? nowMs);
const toMs = nowMs;
const days = runs.length > 0 ? Math.max(1, Math.ceil((toMs - fromMs) / DAY_MS)) : 0;
const days = requested || runs.length > 0 ? Math.max(1, Math.ceil((toMs - fromMs) / DAY_MS)) : 0;

return {
window: { fromMs, toMs, days },
daily: buildDaily(runs, fromMs, toMs),
// `firstRunMs` rides along (null when nothing ran) so a renderer can pad or trim the daily series
// against the requested edge without re-deriving what the fold already knows.
window: { fromMs, toMs, days, firstRunMs },
// Daily buckets still start at the first observed run, NOT the requested edge: the sparkline's
// gap-day discipline covers interior quiet days, but a month of leading zeros on a young
// deployment would compress the visible history into the last few cells.
daily: buildDaily(runs, firstRunMs ?? fromMs, toMs),
byFlow: buildByFlow(runs, subs, pricing),
byModel: buildByModel(runs),
plans: buildPlans(runs, subs, pricing, days),
Expand Down Expand Up @@ -288,6 +317,11 @@ function buildByFlow(runs, subs, pricing) {
}
byFlow.push({
flow,
// The machine key beside the display label: null for the no-flow bucket, the raw flow name
// otherwise. The display label once leaked into the what-if filter, where `"(no flow)"` matches
// no record and a fully ledgered bucket rendered the seeded band -- key and label never share a
// field again (issue #175).
flowKey: flow === "(no flow)" ? null : flow,
runs: members.length,
tokens: members.reduce((sum, m) => sum + (m.record.tokens?.total ?? 0), 0),
cost: combineContributions(members.map((m) => m.contribution)),
Expand Down Expand Up @@ -488,11 +522,16 @@ function peakWindow(w, attributed) {
function buildProvenance(runs, piAiPin) {
let runsUnmetered = 0;
let runsUnledgered = 0;
let runsLedgerTruncated = 0;
let ratesDrifted = 0;
for (const { record } of runs) {
if (!record.tokens) runsUnmetered += 1;
// An empty ledger degrades exactly like an absent one -- the flat totals are all the reader has.
else if (!(record.usage && Array.isArray(record.usage.models) && record.usage.models.length > 0)) runsUnledgered += 1;
// The meter caps the ledger at 8 named rows and folds the rest into `other` (usage.truncated,
// INT-RUN-HISTORY-FILE-CONTRACT). Such a run's per-model attribution is partly anonymous, and a
// fold that states unmetered and unledgered runs but not this one would be selectively honest.
if ((record.usage?.truncated ?? 0) > 0) runsLedgerTruncated += 1;
const piAi = record.usage?.piAi ?? null;
// Drift needs BOTH versions: an unknown pin cannot accuse a record of having been priced elsewhere.
if (piAi !== null && piAiPin !== null && piAi !== piAiPin) ratesDrifted += 1;
Expand All @@ -502,6 +541,7 @@ function buildProvenance(runs, piAiPin) {
runsTotal: runs.length,
runsUnmetered,
runsUnledgered,
runsLedgerTruncated,
ratesDrifted,
piAiPin,
};
Expand Down
47 changes: 23 additions & 24 deletions admin/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { buildGraphModel } from "./graph-model.mjs";
import { matchesKey } from "./keys.mjs";
import { box, meter, clip, fmtUsd, makeLineInput } from "./panel.mjs";
import { makeStyler, frame, RULE } from "./style.mjs";
import { foldCosts, whatIfFlow } from "./costs.mjs";
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";

const KEY_HINTS = "[p]ause [r]esume [q]uit";
// Fetch the read-model's full window (listRuns clamps at 50) but render a cursor-following viewport of
Expand All @@ -56,11 +56,11 @@ const MIN_WIDTH = 8;
// Drill-in views (TRIGGER_DETAIL, RUN_DETAIL) are small; they frame to a compact width and center within
// the wider overlay rather than stretching a handful of key/value lines across the full LIST width.
const DRILL_WIDTH = 70;
// COSTS: the spend windows `t` cycles through, and the staleness bound the poll tick refreshes against.
// The fold itself is cheap, but the scan behind fetchCosts reads EVERY run sidecar in the window -- a
// per-second full-directory read is the kind of quiet load a dashboard must not add, so while the view
// is open a poll tick refreshes the fold only once the last fetch is older than this.
const COSTS_WINDOWS = ["7d", "30d", "mtd"];
// COSTS: the staleness bound the poll tick refreshes against (the `t`-cycled windows themselves are
// costs.mjs' COSTS_WINDOWS -- one list beside the fold whose proration they denominate). The fold is
// cheap, but the scan behind fetchCosts reads EVERY run sidecar in the window -- a per-second
// full-directory read is the kind of quiet load a dashboard must not add, so while the view is open a
// poll tick refreshes the fold only once the last fetch is older than this.
const COSTS_STALE_MS = 10_000;
// GRAPH: the cursor-following row window, on the RUNS_VIEWPORT/TAIL_VIEWPORT precedent -- a fixed
// bound with no height dependency, so an unknown terminal height changes nothing. The graph REFRESHES
Expand Down Expand Up @@ -133,11 +133,12 @@ export function createDashboardDeps(paths: any) {
*/
fetchCosts({ windowKey }: any) {
const nowMs = Date.now();
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: costsWindowSinceMs(windowKey, nowMs), nowMs });
const sinceMs = costsSinceMs(windowKey, nowMs);
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
if (!Array.isArray(records)) return { unreachable: (records as any)?.unreachable ?? "scan failed" };
const subsView: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
const subscriptions = Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [];
const fold = foldCosts({ records, subscriptions, pricing, nowMs, piAiPin: pricing.piAiVersion() });
const fold = foldCosts({ records, subscriptions, pricing, nowMs, piAiPin: pricing.piAiVersion(), sinceMs });
return { fold, records, subscriptions };
},
/**
Expand Down Expand Up @@ -353,7 +354,7 @@ export function makeDashboard({
wi.target = wi.targets[wi.index] ?? null;
wi.result =
wi.target !== null && typeof deps?.whatIf === "function"
? deps.whatIf({ records: costs.data?.records ?? [], flow: wi.flow, target: wi.target })
? deps.whatIf({ records: costs.data?.records ?? [], flow: wi.flowKey, target: wi.target })
: null;
};

Expand Down Expand Up @@ -716,7 +717,10 @@ export function makeDashboard({
const row = (costs.data?.fold?.byFlow ?? [])[costsSel];
const targets = whatIfTargets(costs.data);
if (!row || targets.length === 0) return;
costs.whatIf = { flow: row.flow, targets, index: 0, filter: false, input: null };
// flowKey is the MACHINE key (null for the no-flow bucket -- whatIfFlow matches `flow ?? null`,
// and the "(no flow)" display label matches no record); flowLabel is what the header prints.
// The `in` check keeps a fold without flowKey working: for real flows the two are identical.
costs.whatIf = { flowKey: "flowKey" in row ? row.flowKey : row.flow, flowLabel: row.flow, targets, index: 0, filter: false, input: null };
computeWhatIf();
tui?.requestRender?.();
return;
Expand Down Expand Up @@ -1789,17 +1793,6 @@ function graphHints(inner: number, styler: any): string {

// ── COSTS view (issue #53): full-width like LIST, every body line exactly `inner` visible columns ──────

/** The scan cutoff for a COSTS window key: a fixed span for "7d"/"30d", the start of the current UTC
* month for "mtd" -- the calendar month is the unit subscriptions are priced in, so "how is this month
* going" needs the month's own left edge, not a sliding 30 days. */
function costsWindowSinceMs(windowKey: string, nowMs: number): number {
const day = 24 * 60 * 60 * 1000;
if (windowKey === "7d") return nowMs - 7 * day;
if (windowKey === "30d") return nowMs - 30 * day;
const now = new Date(nowMs);
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/** The frame-title window label: `Aug 2026 (mtd)` for the calendar window, `last 7d`/`last 30d` else. */
Expand All @@ -1825,6 +1818,9 @@ function whatIfTargets(data: any): any[] {
const add = (provider: any, id: any) => {
if (typeof provider !== "string" || typeof id !== "string") return;
if (provider === "unknown" || id === "unknown") return;
// The ledger's 8-row overflow bucket is an aggregation artifact, not a model: getPricedModel of
// ("other","other") is null, so offering it would silently degrade the estimate to the seeded band.
if (provider === "other" && id === "other") return;
const key = `${provider}/${id}`;
if (seen.has(key)) return;
seen.add(key);
Expand Down Expand Up @@ -2043,7 +2039,7 @@ function costsTableLines(fold: any, table: string, costsSel: number, inner: numb
function whatIfLines(wi: any, data: any, inner: number, styler: any): string[] {
const out: string[] = [];
const t = wi.target;
const header = styler.bold(styler.fg("accent", `WHAT-IF ${wi.flow} → ${t ? `${t.provider}/${t.id}` : "?"}`)) +
const header = styler.bold(styler.fg("accent", `WHAT-IF ${wi.flowLabel} → ${t ? `${t.provider}/${t.id}` : "?"}`)) +
styler.fg("dim", ` (${wi.index + 1}/${wi.targets.length} · w next · / search)`);
out.push(fitLine(header, inner, styler));
const r = wi.result;
Expand All @@ -2054,11 +2050,11 @@ function whatIfLines(wi: any, data: any, inner: number, styler: any): string[] {
// like an answer. The note names the tracking id so the band cannot pass as a measurement.
out.push(fitLine(styler.fg("warning", `~~${fmtUsd(r.low)}–${fmtUsd(r.high)} seeded`) + styler.fg("dim", ` · ${r.note ?? "unmeasured (OQ-002)"}`), inner, styler));
} else {
const current = (data?.fold?.byFlow ?? []).find((x: any) => x.flow === wi.flow)?.cost?.usd;
const current = (data?.fold?.byFlow ?? []).find((x: any) => x.flow === wi.flowLabel)?.cost?.usd;
const delta = typeof current === "number" ? r.usd - current : null;
const deltaPart = delta === null ? "" : ` (${delta <= 0 ? "−" : "+"}${fmtUsd(Math.abs(delta))} vs current)`;
out.push(fitLine(styler.fmtCost({ usd: r.usd, class: "estimated", floor: false }) + styler.fg("dim", `${deltaPart} · rates@${r.ratesVersion ?? "?"}`), inner, styler));
const dom = dominantProvider(data?.records, wi.flow);
const dom = dominantProvider(data?.records, wi.flowKey);
if (t && dom !== null && t.provider !== dom) {
out.push(fitLine(styler.fg("dim", "cross-provider: same token profile, tokenizers differ — directional only"), inner, styler));
}
Expand Down Expand Up @@ -2105,6 +2101,9 @@ function provenanceLine(fold: any, inner: number, styler: any): string {
const prov = fold.provenance ?? {};
let text = `~ estimates at pi-ai ${prov.piAiPin ?? "?"} · ${prov.runsUnmetered ?? 0} runs unmetered · ${prov.runsUnledgered ?? 0} not repriceable`;
if ((prov.ratesDrifted ?? 0) > 0) text += ` · ${prov.ratesDrifted} priced under older rates`;
// Only when it happened, like the drift counter: a fanout past the 8-row ledger cap loses per-model
// attribution, and the by-model table must not look complete while rows hide inside `other`.
if ((prov.runsLedgerTruncated ?? 0) > 0) text += ` · ${prov.runsLedgerTruncated} ledgers truncated`;
return fitLine(styler.fg("dim", text), inner, styler);
}

Expand Down
20 changes: 6 additions & 14 deletions admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ import { applyDeploymentPointer, pointerPath, readPointer, takePointerNotice } f
// The only fs use in this module: the skew notice reads one package.json through the wizard's own reader.
// Everything else fs-shaped goes through read-model.mjs by design.
import * as nodeFs from "node:fs";
import { foldCosts, whatIfFlow } from "./costs.mjs";
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";
// The REAL pricing façade. costs.mjs may not hold a module-scope worker/pricing import by contract (the
// fold is pure; tests inject a canned fake) -- index.ts is where the fs-adjacent assembly lives, so the
// injection happens here.
Expand Down Expand Up @@ -1007,25 +1007,15 @@ async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void>
}

// ---- the costs command surface (issue #53) ----

const COSTS_WINDOWS = ["7d", "30d", "mtd"];
// COSTS_WINDOWS and costsSinceMs live in costs.mjs beside the fold: proration denominates on the
// requested window, so the scan cutoff and the fold's sinceMs must come from the one function.

const COSTS_USAGE = "usage: /dispatch costs [7d|30d|mtd] | /dispatch costs whatif <provider/model> --flow <flow>";

const DAY_MS = 24 * 60 * 60 * 1000;

// The pricing façade in the injectable shape the pure fold expects (costs.mjs takes `pricing` as an
// argument by contract; the tests hand it a canned fake, this object is the real one).
const PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };

/** A window's inclusive start: 7d/30d count back from now; mtd is the start of the current UTC month. */
function costsSinceMs(window: string, nowMs: number): number {
if (window === "7d") return nowMs - 7 * DAY_MS;
if (window === "30d") return nowMs - 30 * DAY_MS;
const now = new Date(nowMs);
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
}

/**
* Assemble the costs fold exactly as the dashboard view does: scan the run records for the window, read
* the declared subscriptions (a missing/invalid file degrades to none -- the fold then simply has no plans
Expand All @@ -1035,7 +1025,8 @@ function costsSinceMs(window: string, nowMs: number): number {
*/
function assembleCosts(paths: any, window: string, flow?: string): any {
const nowMs = Date.now();
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: costsSinceMs(window, nowMs), nowMs });
const sinceMs = costsSinceMs(window, nowMs);
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
if (!Array.isArray(records)) return records; // { unreachable }
const subs: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
const scoped = typeof flow === "string" && flow !== "" ? records.filter((r: any) => (r?.flow ?? null) === flow) : records;
Expand All @@ -1045,6 +1036,7 @@ function assembleCosts(paths: any, window: string, flow?: string): any {
pricing: PRICING,
nowMs,
piAiPin: piAiVersion(),
sinceMs, // the same instant the scan cut at -- proration denominates on the requested window
});
return { fold };
}
Expand Down
3 changes: 3 additions & 0 deletions admin/src/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ function windowFacts(w) {
function provenanceCounts(prov) {
const parts = [`unmetered ${prov.runsUnmetered ?? 0}`, `no ledger (not re-priceable) ${prov.runsUnledgered ?? 0}`];
if ((prov.ratesDrifted ?? 0) > 0) parts.push(`rates drifted ${prov.ratesDrifted}`);
// Only when it happened (the drift pattern): runs whose ledger folded rows into `other` past the
// 8-row cap -- their per-model attribution is partly anonymous, and silence would claim otherwise.
if ((prov.runsLedgerTruncated ?? 0) > 0) parts.push(`ledgers truncated ${prov.runsLedgerTruncated}`);
return parts.join(" · ");
}

Expand Down
Loading
Loading