Skip to content

SMOODEV-4057: price streamed turns instead of recording $0 - #208

Open
brentrager wants to merge 3 commits into
mainfrom
SMOODEV-4057-stream-usage
Open

brentrager wants to merge 3 commits into
mainfrom
SMOODEV-4057-stream-usage

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Jira: https://smooai.atlassian.net/browse/SMOODEV-4057 · pearl th-c420bf

Problem

~/.smooth/usage.jsonl showed cost_usd = 0 on every Big Smooth turn. The tokens part of the bug was already fixed in core 1.11.0, which sends stream_options.include_usage (#179). Cost was still broken:

  • LiteLLM flushes its x-litellm-response-cost* headers before the completion is priced, so on a stream they read 0, and parse_gateway_cost maps 0 to None.
  • The agent then falls back to ModelPricing::for_model, which prices gpt-6-luna and most gateway models at the free tier.

Solution

  • usage.cost: the cost on the final stream chunk (the one with empty choices) is now read and emitted as StreamEvent::Cost. LiteLLM sends it with include_cost_in_streaming_usage, and OpenRouter always does. On a stream it supersedes the header. A non-streaming call uses it only when no cost header came.
  • Gateway list-price fallback: when a call has no gateway cost, the agent prices its real tokens at the gateway's own input_cost_per_token / output_cost_per_token from /model/info before falling back to ModelPricing.
    • The price book is cached process-wide per gateway and key for 1h, and a failed fetch for 5 min.
    • A 0/0 price counts as unpriced, not free.
    • This path still sets cost_estimated.
    • It is skipped for an injected LlmProvider.
  • New public API: cost::gateway_list_price, resolution::{fetch_model_pricing, parse_model_pricing}. Changeset: minor.
  • Rust only. The other engines still need the same change for parity.

Verification

  • New tests:
    • SSE parsing of the empty-choices usage chunk, with and without a cost.
    • A mocked SSE stream end to end: real usage replaces the chars/4 estimate, and the body cost beats a 0 header.
    • A non-streaming body cost.
    • Price-book parsing, caching, failure memo, and unpriced rows.
    • Two agent-level streamed turns against a fake LiteLLM: one priced at list price ($0.00069 for 1k+1k tokens of gpt-6-luna), and one where the chunk's cost is authoritative and /model/info is never fetched.
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings (0 warnings), cargo test --workspace (633 core tests, all passing).

🤖 Generated with Claude Code

Every streamed turn on llm.smoo.ai recorded cost_usd = 0. LiteLLM flushes
its cost headers before the completion is priced, so on a stream they read
0, and the agent fell back to ModelPricing, which prices gpt-6-luna and
every smooth-* alias at the free tier.

- Read usage.cost from the final (empty-choices) stream chunk and from a
  non-streaming body. On a stream it supersedes the header, since it is
  sent after pricing.
- When a call carries no gateway cost, price its real tokens at the
  gateway's published list price from /model/info before falling back to
  ModelPricing. The price book is cached per gateway+key for an hour, and
  a failed fetch for five minutes. 0/0 rows count as unpriced, not free.
  List-priced costs still set cost_estimated.

Pearl th-c420bf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ffcc369

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator-core Minor
@smooai/smooth-operator-temporal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

brentrager and others added 2 commits October 24, 2026 14:53
#209)

On the bench, six gpt-6-luna turns died on "stream read error: error decoding
response body". chat_stream retried only the initial send, so a body that
broke in transit failed the whole turn. The error was also formatted with {e},
which dropped the source chain, so the log could not say whether the cause was
a reset, a truncation or a timeout.

Body read errors are now a typed StreamInterrupted that carries the full
chain. run_with_channel re-asks the same request up to MAX_STREAM_RETRIES (2)
times with 500ms/1s backoff, and emits AgentEvent::LlmStreamRetry. It re-asks
only while that cannot be observed or repeat anything: the response has not
completed, no answer text has reached the consumer, and no tool call has been
dispatched. Nothing is pushed to the conversation and nothing is costed until
a response completes. An error after a complete response is still surfaced
and not retried.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant