Price a session that switched models per stretch, not at the one it ended on - #128
Conversation
… 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>
|
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
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
Blocker 2:
|
Closes #103
Session::Dataheld onemodeland oneusage, 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 onclaude-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.
Agentgainsusage_by_model. It already added money up per response at the rates in force — that is what made--max-budget-usdimmune to this — and now counts tokens the same way, keyed by the model that was asked. Keyed by model alone:/modelswitches 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-5is how OpenRouter spells one), and a key that cannot be taken apart again is not a key.Data#usagestays 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.Stats.aggregateiterates segments. Per entry, not per segment, for the session count — a session that switched is still one session.spent_usddirectly whenever a budget is set. That is criterion 4: the line andBudgetExceededcould not agree after a switch, because one summed per turn and the other priced the lot at the current model.smith sessions exportprints 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_agentand 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 statslists a session under every model it used, and still counts it as one session.n/a— not a sum quietly missing a part. Same rule as a single-model session;pricing.crwould rather say nothing than say something wrong.The known-limitation note is gone
README.mdcarried it explicitly, and criterion 2 asks for its removal once the thing is true. The/modelCHANGELOG 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 throughbuild_agent/persistand 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