Skip to content

Price a session that switched models per stretch, not at the one it ended on - #128

Merged
webmatze merged 3 commits into
mainfrom
issue-103-per-model-cost
Sep 10, 2026
Merged

webmatze merged 3 commits into
mainfrom
issue-103-per-model-cost

Conversation

@webmatze

Copy link
Copy Markdown
Owner

Closes #103

Session::Data held one model and one usage, so everything that turns tokens into money charged an entire session at the rate of whatever model it ended on. The case #99 left behind: 100k prompt + 20k completion on claude-opus-5, then /model claude-haiku-4-5, reported as $0.20 for $1.00 of work.

The shape

Usage is recorded per provider/model stretch and priced per stretch.

  • Agent gains usage_by_model. It already added money up per response at the rates in force — that is what made --max-budget-usd immune to this — and now counts tokens the same way, keyed by the model that was asked. Keyed by model alone: /model switches the model and leaves the client, its key and its connection alone, so the provider is not the agent's to track.
  • Session::UsageSegment — a list, not a hash keyed by "provider/model". A model name may contain a slash (anthropic/claude-sonnet-5 is how OpenRouter spells one), and a key that cannot be taken apart again is not a key.
  • Data#usage stays the total. Plenty reads it and none of that wants to know about models. The split is for pricing, where the rate differs per stretch.
  • The Eine Session verliert ihre Lebenszeit-Nutzung beim Resume #102 baseline gains a sibling, taken at the same moment and for the same reason: a run's split is added to what the session had, not written over it.
  • Stats.aggregate iterates segments. Per entry, not per segment, for the session count — a session that switched is still one session.
  • The running cost line reads spent_usd directly whenever a budget is set. That is criterion 4: the line and BudgetExceeded could not agree after a switch, because one summed per turn and the other priced the lot at the current model.
  • smith sessions export prints the split, in Markdown and JSON, but only when there is more than one segment — one segment is the header again.

No migration

A record written before the split has one model and one block of usage, which is one segment, and is read as exactly that. Old rows are rewritten only when a real turn saves the session anyway. A spec pins this.

One subtlety that cost a spec failure before it was found: a session that has spent nothing gets no segment. Otherwise the model a fresh session merely declares enters the baseline through build_agent and gets merged into the run's real segments — and is then reported as a model that was never asked anything. The first version of this had that bug and the spec caught it as a phantom third model.

Two rules, both now in the README

  • smith stats lists a session under every model it used, and still counts it as one session.
  • A session with any stretch on a model with no known price reports n/a — not a sum quietly missing a part. Same rule as a single-model session; pricing.cr would rather say nothing than say something wrong.

The known-limitation note is gone

README.md carried it explicitly, and criterion 2 asks for its removal once the thing is true. The /model CHANGELOG entry for #94 now points forward to #103 rather than describing the limit as current.

Specs

Two, and each was verified against both halves of the change: reverting the per-model persist (one segment for the ending model) fails them, and so does reverting the per-segment aggregation (entry.segments[0, 1]). One drives a real switch through build_agent/persist and asserts the COST column, Stats.aggregate, the by-model breakdown and the session count — including an explicit assertion that the figure is not the old ending-model one. The other reads a segment-less record and pins the fallback.

crystal spec: 1434 examples, 0 failures. crystal tool format --check: clean.

🤖 Generated with Claude Code

webmatze and others added 3 commits September 11, 2026 01:34
… one it ended on

`Session::Data` held one `model` and one `usage`, so everything that turns
tokens into money charged a whole session at the rate of whatever model it
happened to end on: `smith stats`, the COST column, the running cost line,
`smith sessions export`. The case #99 left behind — 100k prompt and 20k
completion on `claude-opus-5`, then `/model claude-haiku-4-5` — reported
$0.20 for $1.00 of work. That is the rule `pricing.cr` is built on, broken:
a wrong cost figure is worse than no cost figure.

Usage is recorded per provider/model stretch now, and priced per stretch.
`Agent` already added *money* up per response at the rates in force, which
is what made `--max-budget-usd` immune to this; it now counts tokens the
same way, keyed by the model that was asked. The pair is put back together
in `persist`, where the provider is known — `/model` switches the model and
leaves the client, its key and its connection alone, which is why the agent
need not track one.

A list rather than a hash keyed by "provider/model": a model name may
contain a slash, `anthropic/claude-sonnet-5` being how OpenRouter spells
one, and a key that cannot be taken apart again is not a key.

The baseline from #102 has a sibling, taken at the same moment and for the
same reason: a run's split is added to what the session already had, not
written over it.

The running cost line reads `spent_usd` directly whenever a budget is set,
so it and `BudgetExceeded` cannot disagree after a switch the way they did —
one summed per turn while the other priced the lot at the current model.

Nothing needs migrating. A record written before the split has one model and
one block of usage, which *is* one segment, and is read as exactly that. One
subtlety that cost a spec failure before it was found: a session that has
spent nothing gets no segment at all, or the model a fresh session merely
*declares* would enter the baseline and be reported as a model that was
never asked anything.

Two rules worth knowing, both in the README: `smith stats` lists a session
under every model it used and still counts it once; and a session with any
stretch on an unpriced model reports `n/a` rather than a sum quietly missing
a part.

Two specs, each verified against both halves — reverting the per-model
persist or the per-segment aggregation fails them.

Closes #103

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e /model moves

Review found two ways to lose or misattribute a whole session's history.
Both verified before fixing, both now have a spec that fails without the fix.

`Data#segments` tested `usage.total_tokens.zero?`. That field is whatever
the provider reported: openai, ollama and openrouter all default it to 0
when the key is missing while `prompt_tokens` and `completion_tokens` hold
real numbers, and Anthropic computes it as input plus output, leaving out
billable cache tokens. So a session holding 100k prompt and 20k completion
with a reported total of 0 lost all of it on the next turn — from the
segments, the COST column and `smith stats` — and lost it for good, since
the following baseline reads the truncated list. `Usage#empty?` asks the
four fields that carry the counts, which is the only test that cannot be
lied to.

`switch_model` wrote `session_data.model` and left the record to be saved
with no segments. A record from before the split derives its one segment
from `model`, so the fallback then re-read the entire lifetime as the model
being switched *to*: the issue's own example, $1.00 of opus reported as
$0.20 of haiku, arriving through the door of the feature that motivated
#103. The split is written down before the model moves — once written, the
past cannot be re-read.

`run_cost` no longer short-circuits to `Agent#spent_usd` when a budget is
set. The two agree wherever both are defined, so the disagreement with
`BudgetExceeded` that #103 names is gone either way; where they differ,
`spent_usd` is the wrong one to show. It is the enforcement figure and
counts an unpriced stretch as nothing, so the line printed $0.00 for a model
with no known rate where it used to print n/a, and a partial sum after a
switch to one — answering "unknown" with "free", against the rule
`output.cr` states outright.

Three more, smaller:

An empty segment list is truthy, so `||` in the export stopped there and hid
the index row behind it, turning a never-run session's `$0.00` into `n/a`
while the COST column still said `$0.00`.

The README claimed one n/a rule for both a session's figure and the grand
total, and only the first is true. They differ on purpose: one number
describes one session and has to be right or absent, the other summarises
many and shows which parts it could not price. Said that way now, in both
places.

`SessionExport.cost_of` had no callers left. `segments` hands back a copy,
so a parked baseline cannot reach into the record it came from.

The spec claim in the PR was also too strong, and the gap was real: nothing
loaded a pre-#103 index from disk, which is the fallback that actually
matters, since `smith stats` and the COST column read the index and not the
session file. There is now a spec that writes one by hand — this build could
only ever write the new shape — and it fails when that fallback is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review, final round. Both are leftovers from the commit before the one that
deleted the `spent_usd` short-circuit, and both now say the opposite of the
code they sit next to — the doc comment above `run_cost` contradicts the
in-body comment eight lines below it.

The reason each gives was never the point anyway. What `run_cost` and
`BudgetExceeded` needed was to stop disagreeing after a switch, and pricing
per stretch does that from this side; reading the agent's own total was the
obvious way to get there and the wrong one, which the surviving comment
already explains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@webmatze

Copy link
Copy Markdown
Owner Author

Review durch einen Reviewer-Agenten, drei Runden: REQUEST CHANGES, dann APPROVE WITH NITS, dann grün. Zwei der Befunde waren Wege, die Geschichte einer Session zu verlieren oder falsch zuzuordnen.

Blocker 1: der Leerheits-Guard prüfte ein Feld, das der Provider meldet

Data#segments testete usage.total_tokens.zero?. total_tokens ist aber kein berechnetes, sondern ein gemeldetes Feld: openai.cr:266, ollama.cr:281 und openrouter.cr:327 setzen es auf 0, wenn der Provider den Schlüssel weglässt, während prompt_tokens und completion_tokens echte Zahlen tragen. Anthropic rechnet es als input+output und lässt abrechenbare Cache-Tokens weg.

Eine Session mit 100k Prompt- und 20k Completion-Tokens und gemeldeter Null verlor beim nächsten Turn alles — aus den Segmenten, der COST-Spalte und smith stats — und zwar endgültig, weil die nächste Baseline die gekürzte Liste liest. Data#usage hielt die 120.120 weiter: genau der Pfad, auf dem Skalar und Segmente auseinanderlaufen können.

Usage#empty? fragt jetzt die vier Felder, die die Zählungen tragen. Der Reviewer hat gegengeprüft, dass das nicht bloß ein richtiger Test ist, sondern der: es ist genau dann wahr, wenn Pricing.cost unter jeder Ratentabelle 0.0 liefert. Alle acht Konstruktionsstellen von Usage bauen aus prompt/completion/cache — keine kann Geld außerhalb dieser vier kodieren.

Blocker 2: /model schrieb die Vergangenheit um

switch_model setzte session_data.model und überließ den Rest dem Speichern. Ein Datensatz von vor #103 trägt keine Segmente und leitet sein eines aus model ab — der Fallback las also die gesamte Lebenszeit als das Modell, auf das gerade gewechselt wurde.

Das Beispiel aus dem Issue selbst, auf diesem Branch reproduziert: 100k+20k echt auf claude-opus-5, resume, /model claude-haiku-4-5, kein Turn, quit → COST $1.00 → $0.20. Nicht vorübergehend: session.json trägt danach model: haiku bei weiter leeren Segmenten, also leitet jedes spätere Resume denselben falschen Split neu ab.

main verhält sich identisch — es ist also kein Regress dieses PRs. Aber der PR-Text und die README behaupteten beide, das könne nicht passieren. Der Split wird jetzt festgeschrieben, bevor das Modell sich bewegt. Der Reviewer hat die Ordnungs-Gefahren durchgespielt: fünfmal /model hintereinander samt abgelehntem Namen, No-Op und bloßem Aufruf → weiterhin ein Segment mit 120.000 und $1.00; auf einem Datensatz, der bereits Segmente hat, ist es eine Selbstzuweisung einer Kopie.

spent_usd war die falsche Zahl zum Anzeigen

Das Issue bot als Kriterium 4 an, die laufende Kostenzeile bei gesetztem Budget direkt aus Agent#spent_usd zu lesen. Das habe ich zuerst getan — und es ist falsch: spent_usd ist die Durchsetzungs-Zahl und zählt eine Strecke ohne bekannte Rate als nichts. Mit --max-budget-usd und einem unbepreisten Modell hätte die Zeile $0.00 gedruckt, wo main n/a druckt, und --json hätte "cost_usd": 0.0 statt null geliefert — gegen die Regel, die output.cr:353 wörtlich formuliert: „a caller must not read 'unknown' as 'free'".

Jetzt wird immer über die Segmente bepreist. Der Reviewer hat über einen echten Wechsel gemessen: spent_usd und run_cost unterscheiden sich um exakt 0.0. Der Widerspruch mit BudgetExceeded, um den es dem Kriterium ging, ist also beseitigt — nur eben von der ehrlichen Seite. Die Durchsetzung selbst ist unberührt: over_budget? liest weiter spent_usd.

Die n/a-Regel ist geteilt, nicht vereinheitlicht

Die Zahl einer Session ist n/a, sobald irgendeine ihrer Strecken auf einem unbepreisten Modell lief — kein Betrag, dem still ein Teil fehlt. Die Gesamtsumme in smith stats macht es weiter umgekehrt: sie addiert, was sie bepreisen kann, und zeigt die unbepreisten Modelle als eigene n/a-Zeilen in der Aufschlüsselung. Das ist Absicht und steht mit Begründung in README und CHANGELOG: eine Zahl beschreibt eine Session und muss stimmen oder fehlen, die andere fasst viele zusammen und sagt, welche Teile sie nicht bepreisen konnte.

Die Spec-Lücke war real

Mein PR behauptete, ein Spec halte die Kompatibilität fest. Der Reviewer zeigte: nichts lud einen echten Vor-#103-Index von der Platte — und genau der ist der Fallback, auf den es ankommt, weil smith stats und die COST-Spalte den Index lesen und nicht die Session-Datei. Das Löschen von IndexEntry#segments' Fallback ließ die gesamte Suite grün.

Jetzt schreibt ein Spec eine Vor-#103-index.json von Hand — dieser Build könnte nur die neue Form erzeugen — und fällt mit got: 0, wenn der Fallback entfernt wird. Jeder der drei neuen Specs fängt genau seinen Fehler und keinen anderen.

Was sonst gegengeprüft und sauber war

Mehrfaches Persistieren verdreifacht kein Segment (die Falle, vor der #102 warnt), Resume mit Rückwechsel dupliziert nichts, ein Fork startet ohne Segmente, ein handgeschriebener Vor-#103-Datensatz liefert identische Zahlen wie main, kein JSON::Serializable::Strict irgendwo — ein älterer Build ignoriert das neue Feld einfach —, Subagenten falten ihre Nutzung nicht in die des Elternteils, und same_target? benutzt dasselbe Pricing.key_for, nach dem Stats gruppiert, sodass Merge und Aggregation nicht über Identität uneins werden können.

crystal spec: 1437 Beispiele, 0 Fehler. crystal tool format --check: sauber. CI grün auf beiden Plattformen.

@webmatze
webmatze merged commit 04baafd into main Sep 10, 2026
2 checks passed
@webmatze
webmatze deleted the issue-103-per-model-cost branch September 10, 2026 23:59
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.

Kosten pro Modell: eine Session, die das Modell wechselt, wird falsch bepreist

1 participant