Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ The built-in commands, resolved before skill expansion (a skill of the same name

`/model` is the one built-in that works both bare and with an argument: on its own it reports the model and provider in use, and with a name it switches the model from the next request onward. Only the name on the wire changes — the provider client, its API key and its connection stay as they are, which is exactly what `-m` decides at startup. The new model is written to the session immediately, so `smith resume` comes back on it. Switching *provider* is not offered: that needs a different client and a different API key, so it stays a restart with `--provider`.

**Known limitation — cost reporting after a switch.** A session records *one* model, so a session that switched models is reported under the model it ended on: `smith stats`, the `COST` column of `smith sessions` and the running cost line price its whole token usage at that model's rate. Splitting a session's usage per model is a larger change to the session record than switching the model is. `--max-budget-usd` is *not* affected — it adds each turn up at the rates that were in force when that turn was billed, so a switch never moves money already spent.
**Cost reporting after a switch.** A session records what each model used separately, so `smith stats`, the `COST` column of `smith sessions`, the running cost line and `smith sessions export` all price each stretch at its own rate rather than at the model the session happens to have ended on. `smith stats` lists the session under every model it used, and counts it once. A session recorded before this existed has no split and is read as the one model it named, which is what it always meant — nothing needs migrating. A *session's* figure — the `COST` column, the export — is `n/a` if any stretch of it ran on a model with no known price, rather than a sum quietly missing one part; the same rule that has always governed a single-model session. The grand total in `smith stats` is the other way round, as it always has been: it adds up what it can price and shows the unpriced models as their own `n/a` rows in the breakdown, so a partial total is visible as partial rather than silent. The difference is deliberate — one number describes one session and has to be right or absent, the other summarises many and says which parts it could not price.

The name is not validated against a list, because there is no honest offline list of every model a provider will accept — a hardcoded one would reject models released next week. What is checked is what can be: a name must be a single word, and a provider name given by mistake (`/model anthropic`) is named as such. A model that does not exist is rejected by the provider at the next request and reported as a turn error; the session stays open, and another `/model` puts it right.

Expand Down
143 changes: 143 additions & 0 deletions spec/smith/session_usage_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ class Smith::CLI
build_agent(provider, session_data)
end

def switch_model_for_spec(session_data : Smith::Session::Data, agent : Smith::Agent, name : String) : Nil
switch_model(session_data, agent, name)
end

def persist_for_spec(session_data : Smith::Session::Data, agent : Smith::Agent) : Nil
persist(session_data, agent)
end
Expand Down Expand Up @@ -269,6 +273,145 @@ describe "a session's lifetime usage across resumes" do
end
end

it "prices a session that switched models at both rates, not at the one it ended on" do
# #103. `claude-opus-5` and `claude-haiku-4-5` are an order of magnitude
# apart, so pricing everything at the model a session happens to end on is
# not a rounding error — it is the difference the report exists to show.
with_cli do |cli|
session = cli.store_for_spec.create(model: "claude-opus-5", provider: "anthropic")
agent = cli.build_agent_for_spec(BillingProvider.new, session)
# What `-m` decides at startup; the harness's provider would otherwise
# answer on its own default and the switch below would be the second of
# three models rather than the second of two.
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.

agent.send("cheap turn")
cli.persist_for_spec(session, agent)

# Two segments, one per model, each with its own half of the tokens.
saved = cli.store_for_spec.load(session.id)
saved.segments.map(&.model).sort!.should eq(["claude-haiku-4-5", "claude-opus-5"])
saved.segments.each(&.usage.total_tokens.should eq(120))
# And the total is still the total.
saved.usage.total_tokens.should eq(240)

# The COST column and `smith stats` both price per segment now.
entry = cli.store_for_spec.list.find { |row| row.id == session.id }.not_nil!
opus = Smith::Pricing.estimate(TURN_USAGE, "anthropic", "claude-opus-5").not_nil!
haiku = Smith::Pricing.estimate(TURN_USAGE, "anthropic", "claude-haiku-4-5").not_nil!

entry.cost.not_nil!.should be_close(opus + haiku, 1e-9)
Smith::Stats.aggregate([entry]).cost.not_nil!.should be_close(opus + haiku, 1e-9)

# The old behaviour, for contrast: everything at the ending model would
# have been this, and it is not what is reported any more.
ended_on = Smith::Pricing.estimate(saved.usage, "anthropic", "claude-haiku-4-5").not_nil!
entry.cost.not_nil!.should_not be_close(ended_on, 1e-9)

# `smith stats` breaks it down by model rather than filing it under one.
by_model = Smith::Stats.aggregate([entry]).by_model
by_model.map(&.model).sort!.should eq(["claude-haiku-4-5", "claude-opus-5"])
# One session, counted once, however many models it used.
Smith::Stats.aggregate([entry]).with_usage.should eq(1)
end
end

it "reads a record written before the split as the one model it recorded" do
# No migration: an old session file has a total and a model and no
# segments, which is exactly one segment, and it prices as it always did.
with_cli do |cli|
session = cli.store_for_spec.create(model: "claude-opus-5", provider: "anthropic")
session.usage = TURN_USAGE
session.usage_segments.clear
cli.store_for_spec.save(session)

entry = cli.store_for_spec.list.find { |row| row.id == session.id }.not_nil!
expected = Smith::Pricing.estimate(TURN_USAGE, "anthropic", "claude-opus-5").not_nil!

entry.cost.not_nil!.should be_close(expected, 1e-9)
Smith::Stats.aggregate([entry]).by_model.map(&.model).should eq(["claude-opus-5"])
end
end

it "reads a pre-#103 index written on disk exactly as it always did" do
# The fallback that matters is `IndexEntry`'s: `smith stats` and the COST
# column read the index, not the session file, and a real installation's
# index.json has no `usage_segments` at all. Written out by hand rather
# than produced by this build, which could only ever write the new shape.
with_cli do |cli|
dir = cli.store_for_spec.sessions_dir
Dir.mkdir_p(dir)

File.write(File.join(dir, "index.json"), <<-JSON)
[{"id":"session-old","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z",
"first_prompt":"before the split","message_count":2,
"provider":"anthropic","model":"claude-opus-5",
"usage":{"prompt_tokens":100000,"completion_tokens":20000,"total_tokens":120000}}]
JSON

entry = cli.store_for_spec.list.find { |row| row.id == "session-old" }.not_nil!
expected = Smith::Pricing.estimate(entry.usage.not_nil!, "anthropic", "claude-opus-5").not_nil!

entry.segments.size.should eq(1)
entry.cost.not_nil!.should be_close(expected, 1e-9)

agg = Smith::Stats.aggregate([entry])
agg.cost.not_nil!.should be_close(expected, 1e-9)
agg.by_model.map(&.model).should eq(["claude-opus-5"])
agg.total_tokens.should eq(120_000)
end
end

it "keeps a lifetime whole when the provider reported no total_tokens" do
# `total_tokens` is whatever the provider said, and three of the four
# adapters default it to 0 when the key is missing. Testing emptiness
# against it would have thrown a real history away on the next turn — and
# unrecoverably, since the next baseline reads the truncated list.
with_cli do |cli|
session = cli.store_for_spec.create(model: "claude-opus-5", provider: "anthropic")
session.usage = Smith::LLM::Usage.new(100_000, 20_000, 0)
cli.store_for_spec.save(session)

resumed, agent = resume(cli, session.id)
agent.send("one more")
cli.persist_for_spec(resumed, agent)

saved = cli.store_for_spec.load(session.id)
saved.segments.sum(&.usage.billed_prompt_tokens).should eq(100_100)
saved.segments.sum(&.usage.completion_tokens).should eq(20_020)
end
end

it "does not re-attribute an old session's history to the model it switches to" do
# A record from before the split derives its one segment from `model`. If
# `/model` moved that field first, everything the session ever spent would
# be re-read as the new model's — #103's own bug, arriving through the
# door of the feature that motivated it.
with_cli do |cli|
session = cli.store_for_spec.create(model: "claude-opus-5", provider: "anthropic")
session.usage = Smith::LLM::Usage.new(100_000, 20_000, 120_000)
session.usage_segments.clear
cli.store_for_spec.save(session)

before = cli.store_for_spec.list.find { |row| row.id == session.id }.not_nil!.cost.not_nil!

loaded = cli.store_for_spec.load(session.id)
agent = cli.build_agent_for_spec(BillingProvider.new, loaded)
cli.switch_model_for_spec(loaded, agent, "claude-haiku-4-5")
cli.store_for_spec.save(loaded)

after = cli.store_for_spec.list.find { |row| row.id == session.id }.not_nil!.cost.not_nil!
after.should be_close(before, 1e-9)

# And it stays put across a reload, rather than being re-derived wrong.
reloaded = cli.store_for_spec.load(session.id)
reloaded.segments.map(&.model).should eq(["claude-opus-5"])
reloaded.model.should eq("claude-haiku-4-5")
end
end

it "leaves --max-budget-usd a per-run limit" do
# The budget runs off `spent_usd`, which the agent counts for itself and
# which no baseline touches. A resumed session with a long history starts
Expand Down
13 changes: 13 additions & 0 deletions src/smith/agent.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

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.

# 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
Expand Down
62 changes: 58 additions & 4 deletions src/smith/cli.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))

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.


# A failed provider call must not report success to a calling script.
exit(renderer.exit_code)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?

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.


# 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.

# 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?
Expand Down
10 changes: 10 additions & 0 deletions src/smith/llm/types.cr
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ module Smith::LLM
@prompt_tokens + @cache_read_tokens + @cache_creation_tokens
end

# Nothing was used. Deliberately not `total_tokens.zero?`: that field is
# whatever the provider reported, and three of the four adapters default
# it to 0 when the key is missing while `prompt_tokens` and
# `completion_tokens` hold real numbers. Anthropic computes it as input
# plus output, which leaves out billable cache tokens. Asking the four
# fields that carry the counts is the only test that cannot be lied to.
def empty? : Bool
billed_prompt_tokens.zero? && @completion_tokens.zero?
end

def initialize(
@prompt_tokens : Int32 = 0,
@completion_tokens : Int32 = 0,
Expand Down
Loading