Skip to content

feat(projects): per-repo cost, and the mixed-era rule that makes it safe - #121

Merged
pitimon merged 1 commit into
mainfrom
feat/102-project-model-and-cost
Jul 25, 2026
Merged

feat(projects): per-repo cost, and the mixed-era rule that makes it safe#121
pitimon merged 1 commit into
mainfrom
feat/102-project-model-and-cost

Conversation

@pitimon

@pitimon pitimon commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the cost half of #102. The surfacing half shipped in #118.

Project rows were keyed project|source|hour and carried no model, so per-repo cost was not merely unimplemented — it was absent from the data model. The README's "which repo, which model, and which hour" promised a repo × model join that had never existed.

The key is now project|source|model|hour and the row carries the model, so computeRowCost — the same function the rest of the product prices with — works per project. A repo total and a model total cannot disagree, because they come from one function.

The transition is the whole job

This is the part that can be wrong in a way nobody notices, and it is what #102 flags.

A legacy row (project|source|hour) and a new row (project|source|model|hour) describe the same bucket at different granularity. Keep both → double-count. Drop either → lose real usage. Neither is acceptable, and neither is what happens here.

The bucket state in cursors.json is re-keyed on load. An old bucket becomes the unknown slot for its hour, keeping its running total and its queuedKey. It then stops receiving new usage — that goes to the model-specific bucket — so it is frozen at its pre-upgrade total and the two sum to the true number. The reader keys model-less rows to that same unknown slot, so a legacy row and its re-keyed bucket are one identity, not two.

Without the re-key the old bucket strands: its key never matches again, new usage restarts from zero under the model key, and the reader still sees the old row. That is the double count, and it is why this is a state migration rather than a key change.

The migration runs in normalizeProjectState, which every parser goes through, so one call covers all seven. Idempotent — a four-segment key is skipped — and it refuses to clobber an existing unattributed bucket, leaving the legacy one in place for a human rather than silently discarding either.

Cost is honest about what it cannot price

A row with no model prices at 0 and is counted as unattributed_tokens rather than folded into the total.

Repo Shows
all rows carry a model $3.00
all rows predate the change no model recordednot $0.00
mixed $3.00+
server sent no cost field at all nothing — absent is not zero

Pricing model-less rows at zero would render "recorded before we recorded models" as "this cost nothing" — exactly the tier #94 was added to avoid.

Test plan

  • ci:local exit 0 — 948 root tests (+15), 266 dashboard (+4)
  • Mixed-era, the risky part: a legacy row and a model row for one bucket count exactly once each (700 + 300 = 1000); repeated appends of one key still collapse to the last; two models in one hour are two slots, not one overwriting the other; legacy rows repeat-append and still collapse
  • Migration: a stranded bucket is re-keyed, keeps its running total and its queuedKey (without which the row re-appends), running it twice changes nothing, a model-keyed bucket is left alone, an existing unattributed bucket is not clobbered, and garbage state does not throw
  • Cost: priced from the model on the row; a legacy row is unattributed rather than a confident $0 and its tokens are still counted; a mixed repo reports the priced part and names the rest
  • Rendering: four component tests including "server sent no cost" → no line at all
  • Every touched function under the 50-line rule

Not in this PR

Backfilling model onto historical rows — out of scope per the issue: the source logs may no longer exist, and unattributed is the honest rendering of "we recorded this before we recorded models".

Closes the cost half of issue 102. The surfacing half shipped in #118.

Project rows were keyed project|source|hour and carried NO model, so per-repo
cost was not merely unimplemented — it was absent from the data model. The
README's "which repo, which model, and which hour" promised a repo x model join
that had never existed.

The key is now project|source|model|hour and the row carries the model, so
computeRowCost — the same function the rest of the product prices with — works
per project. A repo total and a model total cannot disagree, because they come
from one function.

THE TRANSITION IS THE WHOLE JOB, and it is the part that can be wrong in a way
nobody notices.

A legacy row (project|source|hour) and a new row (project|source|model|hour)
describe the SAME bucket at different granularity. Keep both and you
double-count; drop either and you lose real usage. Neither is acceptable, and
neither is what happens here.

The bucket state in cursors.json is re-keyed on load: an old bucket becomes the
`unknown` slot for its hour, keeping its running total AND its queuedKey. It
then stops receiving new usage — that goes to the model-specific bucket — so it
is frozen at its pre-upgrade total and the two sum to the true number. The
reader keys model-less rows to the same `unknown` slot, so a legacy row and its
re-keyed bucket are one identity rather than two.

Without the re-key the old bucket would strand: its key never matches again,
new usage restarts from zero under the model key, and the reader still sees the
old row. That is the double count, and it is why this is a state migration
rather than a key change.

Migration runs in normalizeProjectState, which every parser goes through, so one
call covers all seven. Idempotent — a four-segment key is skipped — and it
refuses to clobber an existing unattributed bucket, leaving the legacy one in
place for a human rather than silently discarding either.

COST IS HONEST ABOUT WHAT IT CANNOT PRICE. A row with no model prices at 0 and
is counted as `unattributed_tokens` rather than folded into the total. The panel
shows "no model recorded" instead of "$0.00" for a repo that is entirely
pre-model, and "$3.00+" for one that is partly priced. Pricing those at zero
would render "recorded before we recorded models" as "this cost nothing" — the
exact tier issue 94 was added to avoid.

19 new tests. The mixed-era ones are the point: a legacy row and a model row for
one bucket count exactly once each; repeated appends of one key still collapse;
two models in one hour are two slots; a stranded bucket keeps its total and its
dedup marker through the re-key; running the migration twice changes nothing.

ci:local exit 0: 948 root tests (+15), 266 dashboard (+4).
@pitimon
pitimon merged commit d99cc3e into main Jul 25, 2026
1 check passed
@pitimon
pitimon deleted the feat/102-project-model-and-cost branch July 25, 2026 23:18
pitimon added a commit that referenced this pull request Jul 26, 2026
15 commits since v0.39.43. Tracker: #125.

User-facing:
- The Projects panel honours the date range. It read no query parameter at
  all, so picking "24h" narrowed every other card while Projects kept showing
  all-time totals with nothing on screen saying so (#118).
- Per-repo cost, with rows written before the change named as unpriced rather
  than shown as a confident $0 (#121).
- Sources with no per-repo attribution are named, instead of their absence
  reading as "that tool cost nothing here" (#118).
- A plan-value card answering what README:32 already promised, labelled as
  list-price-equivalent rather than a saving (#122).
- The Codex quota chip no longer vanishes on a 401 (#119).
- `sync --compact`, which reclaimed 34,924 lines to 5,636 on a real install
  (#117).

Behind the scenes: outbound-privacy validator hardening (#111, #114), avatar
proxy per-hop revalidation, port-aware allowlisting and a real download cap
(#109, #112), a parser conformance ratchet (#116, #120), scripts/graph out of
the product gate (#115), and a usage-limits fixture capture tool (#124).

Version bumped in all four lockstep locations by the `version` hook:
package.json, package-lock.json, both TokenTrackerBar targets, and the Windows
csproj. validate:version-lockstep passes.

WATCH AFTER SHIPPING: #121 migrates cursors.json on every user's first sync
after upgrading, re-keying project buckets from project|source|hour to
project|source|model|hour. Without that the old bucket strands and its usage is
counted twice. Verified read-only against a real 2,015-bucket state — every key
migrated, total_tokens preserved exactly at 6,281,653,062, and all 2,015
queuedKey markers survived, the loss of which would re-append every row. That
is one machine; a differently-shaped state is the residual risk.

ci:local exit 0: 972 root tests, 302 dashboard.

Co-authored-by: itarun.p <itarun.p@somapait.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant