Skip to content

Streamed ReqLLM calls record the usage and cost the provider reported - #255

Merged
deepfates merged 7 commits into
mainfrom
claude/imp-stream-halted
Sep 28, 2026
Merged

deepfates merged 7 commits into
mainfrom
claude/imp-stream-halted

Conversation

@deepfates

@deepfates deepfates commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

What changed

Every provider stream from Imp.Clients.ReqLLM.stream/3 now ends with exactly one terminal event. A stream that did not complete is a failure, and a failed stream records the spend the provider reported.

  • Halted is an end, like done. ReqLLM's StreamResponse.stream is a Stream.resource. Resumed after it has run out, it reports {:halted, _}, not {:done, _}. The client dropped that case with a bare {:halt, state}, so a real stream never emitted the done: true event with usage, model and finish reason. Every real streamed call was recorded with no usage and no cost. The client's reducer only suspends and is only resumed with {:cont, _}, so neither result can mean that a consumer stopped. Both now go through finish_stream/1.
  • ReqLLM's metadata handle is merged in. finish_stream/1 awaits StreamResponse.metadata_handle (bounded at 5 s; an exit or raise adds nothing). It takes the handle's finish reason, and its usage when no chunk reported any. The handle reports :incomplete when the body ended with no termination event (stream_server.ex extract_final_metadata/1).
  • A stream that did not complete is a failure. A provider error in the metadata, or a finish reason of :error, :cancelled or :incomplete, ends in {:error, %Imp.LMError{}} with retryable: true, because the stream had started. It never ends as a completion. ReqLLM.StreamResponse.events/1 ends the first three as :error/:cancelled events. It carries :incomplete as the reason of a :finish event, which Imp does not count as a completion. Before this change, the partial text of such a stream was collected as the answer.
  • Failures carry what arrived, and the record keeps it. The terminal error event carries the metadata accumulated before it. Imp.Streaming.Execution keeps that metadata and returns {:error, reason, partial} to Imp.LM.record/3, where partial is an Imp.Core.LMResponse built from it. record/3 puts usage, cost, estimated_cost (and billing) on the failed :model_response and counts the usage in Imp.Usage. The caller still receives {:error, reason}. The reason for this change: a host sums cost as money spent, and a stream that failed after it was billed must not count as free.
  • Consumer cancellation is unchanged. A consumer that stops early receives no terminal event, and the provider stream is cancelled. The existing test "early halt cleans and cancels once" covers this.
  • Model identity. Imp.Clients.ReqLLM.model_identity/1 (@doc false) returns the provider (as a string) and the model id for every model shape the client accepts: a string, a {provider, opts} tuple (ReqLLM's own two-element shape, naming the model in :id or :model), a {provider, model, opts} tuple, or a map with atom or string keys. The old {provider, binary} clause is dropped: neither ReqLLM nor Imp accepts that shape. The client's response_metadata and Execution.normalize_metadata/2 both use it. Before, a streamed call with an inline map spec or a {provider, opts} spec failed with String.Chars not implemented. A streamed call now records the model the provider reported, as a non-streamed call does, and otherwise the configured model id. The fallback was the whole spec string, so the Imp.Usage key for a stream that reports no model changes from openai/openai:gpt-test to openai/gpt-test. One test was updated for this.
  • relayed_error/1 and the stream path share one provider_error/1 mapping.
  • The Imp.LMError moduledoc now says that an error sent inside a stream arrives without its provider code, because ReqLLM's decoder keeps only "message". Such an error therefore has status nil and is retryable.
  • Stubs that passed metadata_handle: self() now start a real MetadataHandle (tests and Mix.Tasks.Imp.Benchmark.Trace.StreamReqLLM).
  • origin/main (including A model call's cost is the provider's reported charge; the catalog price is estimated_cost #253 and Accept every reasoning effort ReqLLM accepts, including max #254) is merged in; the CHANGELOG keeps both lists under Unreleased, with A model call's cost is the provider's reported charge; the catalog price is estimated_cost #253's breaking cost change under Changed and the fixes under Fixed.

How it was tested

The tests are in test/req_llm_stream_end_test.exs. Every stream there is a real Stream.resource: ReqLLM decoding OpenRouter-shaped SSE from Imp.Test.LocalHTTP, or a Stream.resource stub with a real metadata handle.

  • a provider stream that runs to its end closes with one done event carrying usage and cost
  • an error the provider sends inside the stream ends it as a failure, with what arrived
  • a stream whose body stops with no finish and no [DONE] is not a completion
  • a stream that finishes cancelled or in error is not a completion
  • usage only ReqLLM's metadata handle reported is on the done event
  • the client names the provider and model id of every model shape it accepts
  • a streamed call records the usage and cost the provider reported at the end (through Imp.Run, usage["cost"] and :cost)
  • a stream that fails after the provider reported usage records that spend (through Imp.Run)
  • a failure that carries a partial response counts its usage and returns the reason (Imp.LM.record/3 with Imp.Usage.track)
  • a streamed call records provider and model for tuple and string-keyed specs ({:openai, id: ...} and a string-keyed map; the identity test also covers {:openai, id: ...} and {:openai, model: ...}, and both failed before the {provider, opts} clause was added)

Falsification: I reverted each change, ran the tests, and restored it.

  • In the first round, when the file held four tests (done, in-stream error, cancelled/error, run record): with all of lib/ reverted, all four failed, and with only the {:halted, _} clause reverted, all four failed too.
  • No handle merge: the incomplete and handle-usage tests fail.
  • No :incomplete mapping: the incomplete test fails.
  • model_identity without the string-keyed and 2-tuple clauses: the identity test and the tuple/string-keyed test fail.
  • Execution's old to_string naming: 3 fail, among them the tuple/string-keyed test and the run-record test.
  • Execution returning no partial: the failed-spend run test fails.
  • lm.ex not counting the partial: the record/3 usage test fails.

mix check passed locally: 59 doctests, 9 properties, 3555 tests, 0 failures, 13 skipped (221 excluded).

mix dialyzer.check passed locally: Total errors: 142, Skipped: 142, Unnecessary Skips: 0, with no unused filters. #254 removed the resolve_model/1 pattern_match_cov entry. This branch moves the provider_meta || %{} guard_fail pin to line 1917; its finding and reason are unchanged.

Unsure / open

  • The 5 s bound on the metadata handle is a judgment. Once the provider stream has ended, ReqLLM's collection should already be done. A handle that does not answer within the bound adds nothing, and the stream still ends.
  • A failed call's :model_response does not record the partial text as output, only the error, usage and cost.

…and cost

ReqLLM's stream is a Stream.resource, which reports its end after a
suspension as {:halted, _}. The client ended such a stream without the
terminal event, so every streamed call was recorded with no usage and no
cost. Both ends now close with one terminal event; a stream whose metadata
carries a provider error or finishes :error or :cancelled ends as an
Imp.LMError instead of a completion, and a failure event carries the
metadata that arrived before it. The streamed path also accepts an inline
model spec.
Merge ReqLLM's metadata handle into the terminal event, so an incomplete
stream (no finish, no [DONE]) ends as {:stream_finished, :incomplete} and
usage only the handle reported is kept. A failed stream returns its partial
response to Imp.LM.record/3, which records its usage and cost and returns
the reason alone. Imp.Clients.ReqLLM.model_identity/1 names provider and
model id for every accepted model shape, replacing Execution's copy.
ReqLLM's two-element model tuple is {provider, keyword}, naming the model
in :id or :model; {provider, binary} is not a shape ReqLLM or Imp accepts.
CHANGELOG: #253's breaking cost change moves under Changed.
@deepfates
deepfates merged commit 15b60d4 into main Sep 28, 2026
10 checks passed
@deepfates
deepfates deleted the claude/imp-stream-halted branch September 28, 2026 23:37
@deepfates deepfates mentioned this pull request Sep 29, 2026
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