Skip to content

Cost analytics: per-flow attribution, subscription-aware pricing, and what-if estimation on the admin dashboard #53

Description

@edgehero

What

Metering exists; analysis doesn't. Every run records what it spent, but there is no way to see what a flow costs, what a month costs per model, what a subscription is actually saving, or what would happen to the bill if a flow moved to a different provider. Goal: a costs screen on the admin dashboard — graphs and data per flow/model/day, subscription-aware pricing, and a what-if that re-prices a flow under a different model. Today things like that are effectively untrackable.

What already works (don't redo this)

  • Per-run metering, end to end. The runner emits an exit line with tokens: { input, output, total, cost } (image/runner/run-job.mjs:121-122); token-budget.mjs accumulates turn_end usage and total is the billed total including cache reads/writes. The worker recovers it from a bounded log tail (run-history.mjs parseExitTokens) and buildRecord persists it on the run record.
  • Pricing is pi-ai's, not ours. cost comes from calculateCost over per-provider model tables (35 providers, tiered rates, Anthropic's 2× 1-hour cache-write rule). pi-dispatch holds no pricing table and computes nothing — keep it that way.
  • Caps. budget.mjs day/week/month job slots + the daily token counter (budget:t:*), all overlay-tunable.
  • Surfaces. Cost renders in exactly two places: the RUN_DETAIL drill-in and the /dispatch runs COST column. The dashboard LIST graphs nothing but three job-count meters and one token text line.

Gap 1 — cost is not attributable

The run record (run-history.mjs buildRecord, contract in INT-RUN-HISTORY-FILE-CONTRACT) carries flow and tokens but not model or provider — and not the cache-read/cache-write split that calculateCost prices. So "what did flow X on model Y cost this month" is not reconstructable from history, and no recorded run can be re-priced under different rates. Both are additive nullable fields, the same shape the chain fields and tokens itself took.

Gap 2 — metered and subscription runs are indistinguishable

Subscription-backed providers ship all-zero rate tables (kimi-coding, zai-coding-cn: cost: {input:0, output:0, ...}), so their runs record cost: 0 with real token counts — they look free, not prepaid. Meanwhile openai-codex (the ChatGPT backend) carries API-equivalent rates, so a subscription-covered run looks metered. The cost field silently mixes regimes with no marker. Subscriptions are the cheapest option most of the time right now — which makes this exactly the number that matters.

Gap 3 — subscriptions can't be modeled

Wanted: amortized effective cost. The operator declares each subscription — provider(s) it covers, monthly price, quota shape (token/request windows as far as the vendor states them) — in a small operator-authored file next to triggers.json. The costs screen then shows per subscription: quota burn-down for the current window, effective $/run given actual metered usage, and "this month at API rates would have been $X" as the comparison line. This must handle Claude, ChatGPT, Kimi, Grok, etc. — anything a rate-zeroed or API-equivalent-rated provider entry maps to. Note the credential path already forces the honest design: OAuth/subscription logins are refused at the env boundary (env-allowlist.mjs) — subscription providers arrive as API-key-shaped credentials with zero-rate tables, so an operator-side declaration is the only place the real price can come from.

Gap 4 — no aggregation, no graphs

There is no aggregation anywhere in admin/ — no per-flow totals, no group-by, nothing but per-row tables (listRuns clamps at 50). The only bar primitive is the block meter; there is no time-series primitive. Retention (PI_LOG_RETENTION_DAYS, default 30) bounds any series — say so on the screen rather than pretending at more history.

Gap 5 — no estimation, no what-if

The what-if that matters: "this flow, same token profile, on a different model/provider" — computable by re-pricing recorded token profiles (with the cache split from Gap 1) against pi-ai's rate tables, or against a declared subscription's amortization. For a flow with no history, expected usage has to be seeded: the flow's own measured median first, a coarse benchmark prior (published SWE-bench/DeepSWE-style tokens-per-task figures) only as a clearly labeled fallback. This repo has already been burned by unmeasured guesses (OQ-002, the "$0.5–$5 per job" non-requirement) — estimates must be visibly marked as estimates and never mix silently with metered numbers.

Where it lives

One COSTS view in the dashboard's view machine (a fifth view beside LIST/RUN_DETAIL/TRIGGER_DETAIL/LIVE_TAIL; c is a free key), data assembled in read-model.mjs (the dashboard is asserted fs-free), a matching pure renderer in render.mjs plus a /dispatch costs subcommand for the degraded path. A thin time-series/sparkline primitive belongs next to meter in panel.mjs/style.mjs — same thin-pure-module reasoning as the existing "no TUI framework" comment there.

Explicitly NOT proposed

  • Live pricing fetches from vendor APIs — rates come from the pinned pi-ai tables; drift is a version-bump concern, not a runtime one.
  • A database. DES-RUN-HISTORY-FLAT-FILES-NO-DB stands; aggregation is a read-model fold over the sidecar files, bounded by retention.
  • Auto-switching models based on cost. This screen informs the operator; it changes nothing.

Acceptance

Run records carry model, provider, and the cache-split token counts (additive, nullable); runs on zero-rate subscription providers are marked prepaid, never shown as free; a costs screen shows per-flow/per-model spend over the retention window, subscription burn-down and effective $/run from operator-declared subscriptions with the API-rate comparison, and a what-if re-pricing of a flow under another model or subscription; every estimated number is labeled as an estimate; no new network surface, no new deps beyond a pure drawing primitive, all fs/redis access stays in read-model.mjs.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions