-
Notifications
You must be signed in to change notification settings - Fork 0
Price a session that switched models per stretch, not at the one it ended on #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,16 @@ module Smith | |
| # and ending the run over money never spent when it is to a dearer one. | ||
| getter spent_usd : Float64 = 0.0 | ||
|
|
||
| # What this run used, split by the model in force when each response | ||
| # arrived. The same reason `spent_usd` adds up per response rather than | ||
| # pricing the total once: after `/model` there is no single model the run | ||
| # was charged at, so there is no single one to attribute its tokens to. | ||
| # | ||
| # Keyed by model alone, not provider: `/model` changes the model and | ||
| # leaves the provider, its key and its connection exactly as they were. | ||
| # Whoever persists this knows which provider it belongs to. | ||
| getter usage_by_model : Hash(String, LLM::Usage) = Hash(String, LLM::Usage).new | ||
|
|
||
| def initialize( | ||
| @provider : LLM::Provider, | ||
| @registry : Tools::Registry = Tools::Registry.default, | ||
|
|
@@ -523,6 +533,9 @@ module Smith | |
|
|
||
| private def update_usage(u : LLM::Usage) | ||
| @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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Attribution reads |
||
| # A stretch with no known rate contributes nothing rather than a guess — | ||
| # the CLI is what says so out loud when a budget is set. | ||
| if rates = @rates | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -816,7 +816,13 @@ module Smith | |
| # five call sites. A run start, `smith resume`, `smith -c` and the | ||
| # `/resume` inside either loop all reach a new agent through here, and | ||
| # the last of those is the one a baseline taken at process start misses. | ||
| session_data.try { |data| data.usage_before_run = data.usage } | ||
| session_data.try do |data| | ||
| data.usage_before_run = data.usage | ||
| # Taken here for the same reason and at the same moment as the total | ||
| # above — the run's split has to be added to what the session already | ||
| # had, not written over it. | ||
| data.segments_before_run = data.segments | ||
| end | ||
|
|
||
| agent = Agent.new( | ||
| provider: provider, | ||
|
|
@@ -922,7 +928,7 @@ module Smith | |
| shutdown_bash_jobs | ||
| 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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This call passes |
||
|
|
||
| # A failed provider call must not report success to a calling script. | ||
| exit(renderer.exit_code) | ||
|
|
@@ -989,6 +995,16 @@ module Smith | |
| # it in would count turn one again on turn two, and again on turn three. | ||
| # Against a baseline it is the same answer however often it is written. | ||
| session_data.usage = session_data.usage_before_run + agent.cumulative_usage | ||
| # The provider comes from the record because `/model` cannot change it: | ||
| # it switches the model and leaves the client, its key and its | ||
| # connection alone. The agent therefore counts by model and this is | ||
| # where the pair is put back together. | ||
| session_data.usage_segments = Session.merge_segments( | ||
| session_data.segments_before_run, | ||
| agent.usage_by_model.map do |model, usage| | ||
| Session::UsageSegment.new(session_data.provider, model, usage) | ||
| end | ||
| ) | ||
| session_data.todos = @todos.items | ||
| session_data.context_ratio = agent.context_ratio | ||
| @session_store.save(session_data) | ||
|
|
@@ -1067,7 +1083,7 @@ module Smith | |
| shutdown_bash_jobs | ||
| shutdown_mcp | ||
| persist(session_data, agent) | ||
| renderer.finish(agent.cumulative_usage, cost_for(session_data.provider, agent.model, agent.cumulative_usage)) | ||
| renderer.finish(agent.cumulative_usage, run_cost(session_data.provider, agent)) | ||
| exit(renderer.exit_code) | ||
| end | ||
|
|
||
|
|
@@ -1231,7 +1247,7 @@ module Smith | |
| else | ||
| submit(agent, trimmed) | ||
| persist(session_data, agent) | ||
| if cost = cost_for(session_data.provider, agent.model, agent.cumulative_usage) | ||
| if cost = run_cost(session_data.provider, agent) | ||
| app.cost_text = "#{Smith::Pricing.format(cost)}" | ||
| end | ||
| app.turn_finished | ||
|
|
@@ -1382,6 +1398,15 @@ module Smith | |
| previous = agent.model | ||
| agent.model = name | ||
|
|
||
| # Settle the split *before* the model moves. A record written before | ||
| # #103 carries no segments and derives its one from `model` — so | ||
| # overwriting `model` first would silently re-attribute everything the | ||
| # session ever spent to the model it is switching *to*, which is the | ||
| # very error #103 exists to remove, arriving through the door of the | ||
| # feature that motivated it. Once written down, the past cannot be | ||
| # re-read. | ||
| session_data.usage_segments = session_data.segments | ||
|
|
||
| # Persisted as well as applied, so `smith resume` comes back on the new | ||
| # model — the index row is rebuilt from this same field on save. | ||
| session_data.model = name | ||
|
|
@@ -2069,6 +2094,35 @@ module Smith | |
| Pricing.estimate(usage, provider_name, model, @config.pricing) | ||
| end | ||
|
|
||
| # What the run has cost, priced per stretch rather than all at the model | ||
| # it happens to have ended on. | ||
| # | ||
| # This and `BudgetExceeded` disagreed after a switch: one summed per turn | ||
| # at the rates in force, the other priced the whole run at the model it | ||
| # had arrived at. Pricing per stretch settles that from this side — see | ||
| # below for why it is done here rather than by reading the agent's own | ||
| # total, which was the obvious way to make them agree and the wrong one. | ||
| 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? | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| # Priced from the segments rather than read off `Agent#spent_usd`, which | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| # the issue offered as the shortcut. Where both are defined they agree — | ||
| # same rates, same responses, only grouped differently — 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 enforcement deliberately counts a stretch with | ||
| # no known rate as nothing. Printing that as a *cost* would answer | ||
| # "unknown" with "free", against the rule `output.cr` states outright. | ||
| Session.cost_of( | ||
| agent.usage_by_model.map do |model, usage| | ||
| Session::UsageSegment.new(provider_name, model, usage) | ||
| end, | ||
| @config.pricing | ||
| ) | ||
| end | ||
|
|
||
| # A budget without a price for the model in use is not a budget. Saying so | ||
| # once, loudly, beats letting an automated run believe it is capped. | ||
| private def budget_rates(provider_name : String, model : String) : Pricing::Rates? | ||
|
|
||
There was a problem hiding this comment.
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.modeldirectly, so it never exercises the/modelpath this PR changed: thesession_data.usage_segments = session_data.segmentssettlement (cli.cr:1408), thesession_data.modelwrite, and the save that rebuilds the index row from it. It also leavessession_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 callsswitch_model_for_spec(line 402) is the pre-split one and runs no turns. Drive the switch throughcli.switch_model_for_spec(session, agent, "claude-haiku-4-5")and addsaved.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.