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

@code-review-smith code-review-smith Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Code Review Smith

Reviewed webmatze/smith #128

1. Summary

This is a well-reasoned, carefully-scoped fix: usage is split per provider/model stretch in Agent, persisted as a list of Session::UsageSegment, and priced per stretch everywhere a cost is shown. The compatibility story (old records read as the single segment they always meant, no migration), the usage.empty? test built on billed tokens rather than the provider-reported total_tokens, the array-instead-of-"provider/model"-hash rationale, and the settlement of usage_segments before session_data.model moves in switch_model are all the right calls, and the specs assert the negative case explicitly. My findings are about one unverified interaction (fork), an asymmetry between the two segments fallbacks, segment identity, and a spec that pokes agent.model instead of driving the real /model path.

2. Findings

🟡 Should consider

a) src/smith/session.cr:190 — does the fork path reset usage_segments?
#117 fixed the double-count by zeroing a fork's usage, on the reasoning that Stats.aggregate sums that field over index rows. This change moves the sum to segments, and to_index_entry (line 289) writes the resolved list, so usage_segments now dominates both stats and cost. The diff doesn't touch fork. If fork builds a fresh Session::Data, the property default saves you; if it loads the parent's record and zeroes usage, a fork inherits the parent's whole split and re-introduces exactly the #117 bug — now with a cost column that reads the parent's figure too. Please confirm, add usage_segments.clear next to the usage reset if it's missing, and pin it with a spec mirroring the #117 one. If it is missing, treat this as a must-fix.

b) src/smith/session.cr:125 — IndexEntry#segments lacks the usage.empty? guard that Data#segments (line 264) has.
The PR's own stated invariant is "a session that has spent nothing gets no segment", and the spec at 387 exists to protect it. But the index-side fallback only checks for nil, so a brand-new session's row — written with usage_segments: [] by to_index_entry and a zero usage — resolves to one phantom segment for the model it merely declared. Stats.aggregate (line 73) then counts it in with_usage and creates a models[key] row at 0 tokens / $0.00 for a model that was never asked anything, and session_export.cr:282 only avoids n/a by falling back into that phantom. Two rules for one concept is how this class of bug came about in the first place. Suggest return Array(UsageSegment).new if usage.nil? || usage.empty? here too — noting that it also stops never-run sessions counting towards with_usage, so check the existing stats specs before landing it.

c) src/smith/session.cr:36 — segment identity is the pricing key, not the pair that gets displayed.
same_target? compares Pricing.key_for(provider, model), but merge_segments then keeps the first segment's literal provider/model strings, and those are what the export prints and what ModelStat is constructed from. Any normalisation key_for does — case, an alias, a date suffix — silently collapses two distinct model names into one segment wearing the first one's name, and the second disappears from By model: with its tokens folded in. Segment identity should be the thing that's shown: provider == other.provider && model == other.model. Stats.aggregate already groups by Pricing.key_for at line 88, so the breakdown is unaffected either way, and provider always comes from one field so casing can't split a real stretch.

d) spec/smith/session_usage_spec.cr:289 — the headline spec switches by poking agent.model, not through switch_model.
The spec that names #103 sets agent.model = "claude-haiku-4-5" directly, so it exercises attribution + persist + merge but never the /model path this PR changed: the session_data.usage_segments = session_data.segments settlement (cli.cr:1408), the session_data.model write, and the save that rebuilds the index row from it. It also leaves session_data.model == "claude-opus-5" while the segments say otherwise, so nothing asserts the record ends up naming the model it switched to. The only spec using switch_model_for_spec (line 402) is the pre-split one and runs no turns. Drive this one through cli.switch_model_for_spec(session, agent, "claude-haiku-4-5") — the helper is already there — and add saved.model.should eq("claude-haiku-4-5"), so a regression in the settlement on a post-split record is caught and not just on a pre-split one.

e) src/smith/cli.cr:931 — two different sources for the provider name now feed one helper.
run_cost(provider.name, agent) here, but run_cost(session_data.provider, agent) at 1086 and 1250, while persist pairs every model key with session_data.provider at line 1005. If those two can ever disagree (a run started against a client other than the one the record names), the cost printed at the end of the run and the cost stored for it are priced at different providers' rates — and the stored one is what every later report reads. run_cost exists precisely to make the displayed figure and the enforcement figure agree; pick one provider source of truth for it as well.

🟢 Nitpicks

f) src/smith/cli.cr:2108 — the empty guard is a proxy. agent.usage_by_model.empty? means "nothing was counted" only because both are written in update_usage. agent.cumulative_usage.empty? — the method this PR just added — says the intent directly and can't drift if cumulative_usage is ever seeded from a baseline.

g) src/smith/agent.cr:538 — attribution reads @model at response time. The comment above it claims @model "is what was asked for and answered just now", which holds only while /model cannot be applied mid-turn. Capturing the model when the request goes out and passing it into update_usage keeps that claim true if the command ever becomes applicable while a turn streams.

h) src/smith/cli.cr:2110 — the PR description contradicts the code. The description's bullet says "the running cost line reads spent_usd directly whenever a budget is set"; the code and the CHANGELOG both say the opposite, and deliberately so. Worth correcting in the description so the merged record doesn't describe a design that was rejected.

i) src/smith/session_export.cr:153 — the new usage_by_model JSON field is undocumented. The README paragraph at 1078 says the export prices each stretch at its own rate but never mentions the new key or the By model: markdown block. The export's JSON is a machine-readable contract; a sentence in the README's export section (or the #95 CHANGELOG entry) naming the field and the >1-segment condition would keep consumers from discovering it by diff.

3. Verdict

Verdict: COMMENT


Generated by Code Review Smith v0.2.0 via the smith agent harness.

Comment thread src/smith/session.cr
#
# Empty in a record written before `/model` existed, and in one that has
# never run; `segments` is what to read.
property usage_segments : Array(UsageSegment) = Array(UsageSegment).new

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usage_segments is now what stats and cost read (via to_index_entry at line 289), so the #117 fork fix — which zeroed usage to stop a fork re-adding its parent's history — only still holds if fork also clears this field. The diff doesn't touch the fork path. If fork loads the parent's record and zeroes usage rather than constructing a fresh Data, a fork inherits the parent's whole split and the double count is back, now with a cost column too. Please confirm, add usage_segments.clear beside the usage reset if it's missing, and pin it with a spec mirroring the #117 one; if it is missing this is a must-fix.

Comment thread src/smith/session.cr
# segment, and reading it as one says precisely what it always said. The
# fallback is the whole of the compatibility story — nothing rewrites an
# old row until a real turn saves the session anyway.
def segments : Array(UsageSegment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IndexEntry#segments only guards against nil, while Data#segments (line 264) also guards with @usage.empty? — the invariant this PR states explicitly ("a session that has spent nothing gets no segment"). A brand-new session's index row is written with usage_segments: [] and a zero usage, so it resolves here to one phantom segment for the model the session merely declared: Stats.aggregate counts it in with_usage and opens a models[key] row at 0 tokens / $0.00 for a model never asked anything, and session_export.cr:282 avoids n/a only by falling back into it. Add return Array(UsageSegment).new if usage.nil? || usage.empty? so there is one rule, and check the existing stats specs since this also stops never-run sessions counting towards with_usage.

Comment thread src/smith/session.cr
end

def same_target?(other : UsageSegment) : Bool
Smith::Pricing.key_for(provider, model) == Smith::Pricing.key_for(other.provider, other.model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Segment identity is Pricing.key_for(provider, model), but merge_segments keeps the first segment's literal strings and those are what the export prints and what ModelStat is built from. Any normalisation key_for performs (case, alias, date suffix) collapses two distinct model names into one segment wearing the first name, folding the second's tokens in and erasing it from By model:. Use the displayed pair instead — provider == other.provider && model == other.model; Stats.aggregate already groups by Pricing.key_for at line 88, so the breakdown is unaffected, and provider always comes from a single field so casing cannot split a real stretch.

agent.model = "claude-opus-5"

agent.send("expensive turn")
agent.model = "claude-haiku-4-5"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec named after #103 switches models by assigning agent.model directly, so it never exercises the /model path this PR changed: the session_data.usage_segments = session_data.segments settlement (cli.cr:1408), the session_data.model write, and the save that rebuilds the index row from it. It also leaves session_data.model == "claude-opus-5" while the segments disagree, so nothing asserts the record ends up naming the model it switched to, and the only spec that calls switch_model_for_spec (line 402) is the pre-split one and runs no turns. Drive the switch through cli.switch_model_for_spec(session, agent, "claude-haiku-4-5") and add saved.model.should eq("claude-haiku-4-5"), so a regression in the settlement is caught on a post-split record too, not only on a pre-split one.

Comment thread src/smith/cli.cr
shutdown_mcp
persist(session_data, agent)
renderer.finish(agent.cumulative_usage, cost_for(provider.name, agent.model, agent.cumulative_usage))
renderer.finish(agent.cumulative_usage, run_cost(provider.name, agent))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call passes provider.name while lines 1086 and 1250 pass session_data.provider, and persist pairs every model key with session_data.provider at line 1005. If those two can ever disagree — a run started against a client other than the one the record names — the cost printed at the end of the run and the cost stored for it are priced at different providers' rates, and the stored one is what every later report reads. run_cost exists to make the displayed and enforced figures agree; give it one provider source of truth as well.

Comment thread src/smith/cli.cr
private def run_cost(provider_name : String, agent : Agent) : Float64?
# Nothing counted yet: no segments to price and no model to blame, so
# the answer is the one a zero-usage run always gave.
return cost_for(provider_name, agent.model, agent.cumulative_usage) if agent.usage_by_model.empty?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent.usage_by_model.empty? is a proxy for "nothing was counted", true only because both fields are written together in update_usage. agent.cumulative_usage.empty? — the method this PR adds — states the intent directly and cannot drift if cumulative_usage is ever seeded from a baseline.

Comment thread src/smith/agent.cr
@cumulative_usage += u
# `@model` is what was asked for and answered just now, which is what
# makes this attribution right rather than approximate.
@usage_by_model[@model] = (@usage_by_model[@model]? || LLM::Usage.new(0, 0, 0)) + u

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attribution reads @model when the response arrives, and the comment above claims that is "what was asked for and answered just now" — which holds only while /model cannot be applied mid-turn. Capturing the model as the request goes out and passing it into update_usage keeps that claim true if the command ever becomes applicable while a turn is streaming.

Comment thread src/smith/cli.cr
# the answer is the one a zero-usage run always gave.
return cost_for(provider_name, agent.model, agent.cumulative_usage) if agent.usage_by_model.empty?

# Priced from the segments rather than read off `Agent#spent_usd`, which

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code and the CHANGELOG both say the running cost line is priced from the segments rather than read off Agent#spent_usd, but the PR description states the opposite ("reads spent_usd directly whenever a budget is set"). Correct the description so the merged record doesn't describe the design that was deliberately rejected.

json.field "cost_usd", @cost
# Only where it adds something: one segment is the header again.
if @usage_segments.size > 1
json.field("usage_by_model") do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new usage_by_model JSON key and the By model: markdown block are not documented: README line 1078 says only that the export prices each stretch at its own rate. The export's JSON is a machine-readable contract, so a sentence in the README's export section (or the #95 CHANGELOG entry) naming the field and the more-than-one-segment condition would save consumers from discovering it by diff.

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