Skip to content

Commit 4f0bc70

Browse files
Normalize completion responses at the provider boundary and erase the model type at agent construction (#2257)
* refactor(completion)!: normalize completion responses at the provider boundary Remove the response and construction associated types from `CompletionModel` so the trait describes only what a model *does*, and make the ordinary completion and streaming types concrete. `CompletionResponse` loses `raw_response` and gains the metadata callers actually reached into it for: a normalized `finish_reason`, the stable `provider` descriptor name, the provider-reported `model`, and `message_id`. Provider-native payloads stay reachable through each model's inherent `raw_completion`/`raw_stream`, which share one request, transport, parser, telemetry path, and error path with their normalized counterparts. Three details are load-bearing: - The `Stop` -> `ToolCalls` reconciliation lives in exactly one place (`FinishReason::reconcile_with_output`) and is applied by both the unary setters and `normalize_stream`. Several OpenAI-compatible gateways report a bare `stop` on a tool-calling turn, so a caller branching on `ToolCalls` would otherwise miss the call on the unary path while the streaming path caught it. - Conversions over a wire type shared by many providers take the descriptor name as an input rather than hardcoding one, so a shared shape cannot mislabel the provider that used it. - `StreamingCompletionResponse` records its provider when the stream is opened, so a stream that errors or truncates before its terminal record still reports one. Construction moves to the required `CompletionClient::completion_model`; `ProviderCapabilities` replaces `composes_native_output_with_tools` with plain data a runtime can snapshot. Request construction is unchanged — no cassette fixture differs. * test: migrate the integration suite to the normalized completion boundary Assertions that read normalized metadata now read the fields that replaced `raw_response` (`model`, `finish_reason`, `provider`, `message_id`). Raw-vs-normalized parity tests keep their intent by taking the provider's own response from the model's inherent `raw_completion` and normalizing that same value, rather than issuing a second request — each cassette holds exactly one interaction and fails on both unmatched extra requests and unconsumed ones. Tests that read genuinely provider-specific stream payloads (reasoning metadata, local generation counters) move to `raw_stream`, which is what that escape hatch is for. No cassette fixture changed. * docs(chatgpt): explain why the raw response can lack assistant output /responses answers with an SSE body even for a non-streaming request, so the value raw_completion returns is reassembled from the terminal event — and that event sometimes carries an empty output, with the content living only in the preceding events. Point callers who need full provider fidelity at raw_stream rather than letting them discover this from an empty field. * fix(completion): address independent review of the normalization boundary An adversarial review of the full diff raised 38 findings; 24 survived refutation. The load-bearing ones: Normalization was stated as `TryFrom<(&str, Response)>`, which the orphan rule makes unimplementable outside rig-core — a tuple is not a local type, so no other crate could satisfy the bound. That silently blocked external provider extensions, which are explicitly in scope. It is now a `NormalizeCompletionResponse` trait, implementable on any provider's own response type anywhere. `normalize_stream` counted tool-call *deltas* toward the Stop -> ToolCalls upgrade while the unary path counts only completed calls, so a stream whose tool call never assembled reported `ToolCalls` over a choice containing none — the exact divergence the single-implementation rule is supposed to prevent. Both paths now count only completed calls. `send_compatible_streaming_request` is public and stamped a hardcoded "openai" onto every stream it produced, reintroducing in a public helper the same placeholder hazard this change removes elsewhere. It takes the descriptor name now. Vertex AI built `FinishReason::Other` by `Debug`-formatting the SDK enum, which turned MALFORMED_FUNCTION_CALL into MALFORMEDFUNCTIONCALL. It uses the SDK's own `name()`. Also reverted two behavior changes that had crept in and do not belong to a normalization change: `message_id` was being fed response-scoped ids (`chatcmpl-`, Gemini `responseId`) on five paths even though the agent replays that field into assistant history, and Cohere's usage silently moved from `billed_units` to `tokens`. Both are back to the pre-existing semantics. Three confirmed findings are deliberately untouched because they are identical at the merge base: anthropic's SSE parse-failure guard, bedrock's MaxTokens early yield, and Z.AI's two descriptor names. * fix(completion): correct two over-corrections from the review pass Both were cases of acting on a finding's mechanism without first checking whether it described a reachable harm. The test suite caught both. `message_id` was stripped from five providers on the claim that a response-scoped id would corrupt agent history, since the agent writes it into `Message::Assistant { id }`. That data path is real, but every provider's request conversion drops the id before it reaches the wire (see providers/openai/completion/mod.rs, `id: None`), so nothing is replayed. Stripping it discarded identifiers this change is meant to preserve. They are restored, and the field now documents what it actually holds — providers differ on whether it names the message or the response — and that it is not echoed back. Cohere's usage source had been changed to prefer `tokens`; reverting to `billed_units` kept every existing number stable but left rig reporting zero usage — the documented "no metrics" sentinel — for a response carrying `tokens` and no `billed_units`. Neither was right. `billed_units` stays primary so no caller's numbers move, with `tokens` as a fallback only where rig previously reported nothing. Gemini's Interactions API keeps its continuation handle reachable through `raw_completion`, which is where the pre-refactor tests read it from (`raw_response.id`); it is a `previous_interaction_id` handle, not an assistant message, so the escape hatch is its proper home. * fix(completion): split response-scoped IDs from replayable assistant-message IDs CompletionResponse and StreamFinal gain a response_id field for identifiers that name the response as a whole (OpenAI chat chatcmpl-*, Gemini responseId, Cohere generation IDs, Gemini Interactions interaction IDs). message_id is now reserved for identifiers the provider would recognize on a replayed assistant message (Anthropic msg_*, OpenAI Responses output-message msg_*), which is what agent history promotes into Message::Assistant's id. This stops chat response IDs from being replayed to the Responses API as fabricated output-message IDs. * fix(client): make the completion-model construction hook public ConstructCompletionModel was pub(crate), but the blanket CompletionClient impl over Client<Ext, H> bounds on it, and the orphan rule prevents a downstream crate from implementing CompletionClient for the foreign Client<Ext, H> type. Together that locked out-of-tree generic provider extensions out of completion_model entirely. The hook is now public and documented as the extension point, with a compile probe showing an external extension reaching the blanket impl through public API only. * fix(streaming): withhold terminal records from truncated streams A synthetic FinalResponse was still emitted when a stream reached EOF without the provider's genuine end-of-response event, and (on some paths) after a yielded SSE parse error. Every provider stream now tracks whether the real terminal signal arrived — Anthropic message_delta with stop_reason, OpenAI [DONE]/finish_reason chunk, Responses response.completed, Cohere message-end, Gemini finishReason chunk (REST, gRPC, Interactions), Copilot response.completed — and emits the terminal record only then. Fully delivered tool calls still flush on truncation; parse errors are surfaced without aborting the stream, so a later genuine terminal can still complete it. Regression tests cover EOF-truncation, parse-error-then-EOF, and parse-error-then-terminal for Anthropic, Gemini REST, the shared OpenAI chat path, and Cohere. The contract is documented on StreamFinal. * fix(completion): align conversion inputs and Gemini mappers with the boundary contract - send_compatible_streaming_request takes the provider name as impl Into<String>, so runtime-named gateways can use the shared path. - The Gemini REST finish-reason mapper now treats FINISH_REASON_UNSPECIFIED as absent (None), matching the gRPC mapper's handling of the same wire value instead of surfacing an Other("FINISH_REASON_UNSPECIFIED") that the sibling transport would report as no reason. - FinishReason documents the single policy for provider failure statuses that arrive with parseable output. * docs: cover the telemetry signature and terminal-emission changes in MIGRATING * feat(agent): erase the model type at construction into ModelHandle Erase the typed CompletionModel once, at AgentBuilder::new/ExtractorBuilder::new, into an opaque, cloneable ModelHandle that is itself a CompletionModel with its ProviderCapabilities captured by value at erasure. Agent, AgentBuilder (after new()), AgentRunner, prompt/stream requests, and Extractor carry no model type parameter. Port the ModelSelection hook event and ModelSelectionAction onto the AgentHook stack (registration-order chaining, last selection wins, stop is terminal), per-run using_model defaults, run-local extractor overrides, the runtime_model_routing example, and the runtime_model_swapping suite. * fix(agent): resolve completion-call hooks before model selection Reorder the model-call boundary: completion-call hooks resolve first; only if they proceed does ModelSelection fire, carrying the merged per-turn patch on a new public request_patch field; request preparation then runs against the selected model's captured ProviderCapabilities before the attempt is issued. previous_model now advances immediately before invoking the selected model's unary or streaming operation, so an issued attempt counts even when the provider errors, while a completion-call stop, selection stop, or preparation failure leaves it untouched. Document the ordering contract on both hook events and add blocking/streaming parity tests covering the merged patch, patch-driven routing, stop/preparation-failure suppression, and issued-attempt tracking. * style: apply rustfmt * fix(completion): address independent review of the combined boundary - The shared OpenAI chat-compatible stream no longer aborts on a chunk parse error: the error is surfaced and consumption continues, matching the Anthropic/Gemini parse-error policy, with the missing terminal signal still suppressing the terminal record on truncation. - The Responses API streaming terminal now carries the resp_* response ID (accumulator, Copilot inline path), matching the unary path. - MIGRATING covers the agent-side model-type-parameter removal and runtime model swapping, and no longer claims an Agent's model is fixed. * fix: address code-review findings on the normalized model-swapping PR Correctness: - Reject provider-stream EOF without a terminal record as truncation in the streaming agent, before usage fallback, history commit, or tool dispatch; add text-only and tool-call truncation tests. - Carry response_id on OpenRouter unary, the ChatGPT empty-output fallback, gemini-grpc unary, and vertexai unary responses. - Add #[serde(default)] to DeepSeek Usage and to all Option fields of CompletionResponse/StreamFinal. - Route the mock model's stream through normalize_stream so tests see the same Stop -> ToolCalls reconciliation as real providers. Pre-existing contract violations: - ollama: skip malformed NDJSON lines instead of aborting; use the function name as the streamed tool-call id. - bedrock: MAX_TOKENS ends as a genuine Length terminal; parse failures warn and skip; Metadata always emits the terminal record. - copilot: use Usage::to_normalized (saturating, real completion and reasoning tokens) on the unary path and in telemetry. - vertexai: map cached_content_token_count instead of hardcoding zero. Coverage and tooling: - Nine new streaming tests: core mid-stream-error/truncation, gemini REST transport error, cohere malformed frame, OpenAI Responses truncated EOF, anthropic transport error, xai/gemini-interactions/ ollama truncation. - rig-agent dev-dependencies enable rig-core test-utils so plain `cargo test -p rig-agent` builds. - Candle real-model test exercises the normalized completion()/stream() surfaces. - Raise malformed-SSE-frame logs to warn. Docs: fix the stale gemini example narrative, add StreamFinal response_id and the normalize-bridge pointer to MIGRATING.md, remove dangling/duplicated doc blocks, re-section changelogs, add ModelSelection::new for hook unit tests, document ModelHandle's per-call clone, and make the previous_model comment precise. * fix: address code-review findings on the normalized model-swapping PR CI blockers: - Rerecord three ollama cassettes (local qwen3:4b) whose requests still expected the old empty streamed tool-call ids; tool results now carry the function name as tool_name, matching the unary path. - Convert bedrock's one-arm MessageStop match to an if, fixing clippy::single_match under --all-features -D warnings; reflow the kill-token example's doc list for doc_overindented_list_items. Parity: - Treat OpenAI Responses `response.incomplete` as a genuine terminal on the SSE and WebSocket paths (partial output, usage, and a map_finish_reason-mapped finish reason), matching the unary path; `response.failed` remains an error. Pinned tests rewritten and a WebSocket incomplete-terminal test added. Hardening: - ModelHandle retains the erased model in one shared Arc and clones only the Arc per attempt, so interior-mutable model state persists across attempts; handle docs state the retention guarantee. - MIGRATING documents the agent-level truncation error for streams that end without a terminal record. - Remove a stray blank line in StreamingCompletionResponse. * refactor: erase models through an object-safe trait and drop the Clone supertrait Erasure (rig-agent): - Replace ModelHandle's boxed closure pair with a private object-safe ErasedModel trait and a blanket impl over CompletionModel + 'static — the tower::BoxService shape: one vtable, borrowed futures delegating to the RPITIT methods, no per-attempt model handling beyond Box::pin. - The WasmCompat supertraits carry the wasm cfg fork (as ErasedTool does), removing all four cfg-forked callback type aliases. - Store capabilities snapshot, label, and the unsized erased model in a single Arc<ModelDriver<dyn ErasedModel>> allocation. - Pin the shared-instance invariant structurally: a clone-counting probe model asserts zero model clones across erasure and repeated completion/stream attempts. Trait relaxation (rig-core, breaking for generic code): - CompletionModel no longer requires Clone; the trait demands only async service behavior, tower::Service-style. completion_request gates on `where Self: Sized + Clone`. - build/send/stream funnel through one private into_model_and_request destructuring, removing the terminal methods' model clones while keeping build()'s public behavior identical. - tool_choice_modes in the conformance suite bounds + Clone explicitly, matching its siblings; every other call site is a concrete Clone type and compiles unchanged. - MIGRATING and the rig-core changelog document the relaxation. Verified: workspace check --all-features, wasm32-unknown-unknown check for rig-core/rig-agent/rig, fmt, clippy --all-features -D warnings, rig-agent suite (incl. runtime_model_swapping and conformance), and rig-core --features test-utils all green. No cassettes touched. * fix: address the full third-round review of the normalization PR Correctness (P1): - openai responses websocket: streamed item events now feed the same RawChoiceAccumulator the SSE path uses, so a response.incomplete terminal with an empty output array keeps the partial output that streamed before it, instead of failing with "Response contained no message or tool call". - copilot responses streaming: response.incomplete is a genuine terminal (partial content, Length finish reason), no longer a vague stream error with no terminal record; only response.failed errors. - bedrock streaming: in-flight tool calls are keyed by content_block_index and flushed at ContentBlockStop, so parallel tool calls all arrive (previously a single slot kept only the last), text after a closed tool block is delivered, and malformed tool-call JSON surfaces an Err item instead of silently emitting a ToolCalls terminal with zero calls. Streaming contract (P2): - corrupt frames (invalid JSON) yield an Err item and the stream keeps consuming — openai responses, copilot chat, cohere, ollama; a later genuine terminal still completes the stream. Valid-JSON events with unrecognized shapes are skipped for forward compatibility with new provider event types (xAI's extra Responses events exercised this). - errored streams flush fully-delivered tool calls before ending (shared openai-compatible path and the copilot responses route), matching the clean-EOF truncation path. - a bare [DONE] after only unparseable frames no longer fabricates a zero-usage terminal record. - gemini REST/Interactions wire enums gained untagged Unknown(String) catch-alls so unknown values are preserved verbatim as FinishReason::Other, matching the gRPC mapper, instead of failing the whole payload. - cohere message-end without a delta still emits the terminal record. - the StreamFinal emission contract now documents the three failure shapes (transport error / recoverable parse error / truncation) and that consumers must drain to None. API (breaking, documented in MIGRATING): - CompletionResponse::finish_reason is a private field with a getter, so the Stop -> ToolCalls reconciliation cannot be bypassed by direct assignment. - CompletionModel is implemented for Arc<M> by forwarding, making the "wrap it in an Arc" guidance real through the generic APIs. - identifier/model setters on CompletionResponse and StreamFinal treat empty strings as absent, centralizing a rule previously duplicated at provider call sites. Tests: parallel-tool-call and text-after-tool bedrock coverage, WS incomplete-with-empty-output regression, copilot incomplete terminal, malformed-frame surfacing per provider, unknown-wire-value round trips, a NonCloneModel compile-time traits() probe, and the clone-probe assertions split per path. The misleading "did not yield response.completed" error now names both terminal events, and the stale Clone doc line on CompletionClient is rewritten. * fix: address the reverified fourth-round review findings Correctness: - streaming aggregation: a full reasoning block supersedes its accumulated deltas (correlated by reasoning item id; distinct ids still append), so the aggregated choice and agent history no longer carry the same reasoning twice. - openai chat default profile: unparseable frames surface as Err items instead of Ok(None), so the saw_any_valid_frame gate can no longer produce a completely silent empty stream on the plain OpenAI path. - bedrock: the MessageStop straggler flush is gated on a ToolUse stop reason — a tool block truncated by MaxTokens is dropped with a warning rather than fabricated into a {}-args call or a spurious error item. - openai responses websocket/SSE replay: terminal-body message text is merged per content kind when it never streamed as deltas (the reasoning-deltas-plus-body-text sequence previously dropped the answer); the untested incomplete-with-body-output quadrant is now pinned. Streaming contract: - one flush ordering on every path (shared compat, openai responses SSE, copilot responses): fully-delivered tool calls are yielded before the terminal Err, nothing follows the error, no terminal record — previously the three paths had three behaviors and the flush was invisible to first-Err consumers. Documented on the StreamFinal contract table. - parse policy discriminates on the known event type at every site (responses, copilot chat, cohere, openai chat default): a known event with a schema defect surfaces an Err; unknown event types are skipped for forward compatibility. The ChatGPT buffered SSE fallback fails the completion on corrupt known frames instead of returning silently partial content. xai's reasoning_summary_text.done (which carries text, not delta) now decodes via a serde alias. - CompletionResponse and StreamFinal deserialize through wire-shape mirrors that funnel the validating setters, so finish-reason reconciliation and empty-string filtering also hold for persisted values; serialization is unchanged and round-trips are identity. - gemini interactions InteractionStatus::is_terminal enumerates the known in-flight states (semantic-kernel-style allowlist), so an unknown status reads as terminal. Cleanups: redundant per-provider empty-string filters removed (deepseek, openai, copilot); ollama post-terminal behavior pinned by test. * fix: address the reverified fifth-round review findings Two regressions from the previous round's known-type parse policy, both the same root cause — the discriminator lists were broader than the typed wire models: - Delta-less streamed choices (Azure prepends a prompt_filter_results chunk to every stream when content filtering is enabled, its default) parsed as recognizable-but-broken and surfaced a spurious error item on every Azure request. The choice's delta is now serde-defaulted (openai-compatible and copilot chat chunk models), so the prelude parses as the no-op frame the reference SDKs treat it as (vercel nullish + skip, langchain return-None, semantic-kernel documented skip). - Unmodeled Responses content_part shapes — the refusal and reasoning_text parts real refusal/reasoning-text turns emit — failed the typed decode: spurious error items on SSE, and a hard failure of the whole ChatGPT buffered completion (the part frame precedes the deliverable refusal deltas). ContentPartChunkPart gained an untagged Unknown(Value) catch-all (the Output::Unknown shape); the content still flows via response.refusal.delta. Reasoning aggregation: the full-block supersede is now genuinely id-correlated. The identity table is strict (matching ids or both absent replace; an id on only one side appends instead of clobbering an unrelated item), and when other output cleared the active index (reasoning -> tool call -> completed block) a by-id fallback scan replaces instead of duplicating. The supersede contract is documented on StreamedAssistantContent::Reasoning and in MIGRATING: a full block replaces accumulated deltas with the same id. Also: the websocket/SSE terminal-body merge gates on any streamed text (is_empty, not trim) so all-whitespace turns don't duplicate; gemini interactions RequiresAction is terminal for a poll loop — it never advances without submitted tool results — and documented as a distinct resumable outcome (semantic-kernel's taxonomy). Tests: Azure filter-prelude no-op, refusal content-part frames on the live and buffered paths, interleaved reasoning supersede, id-less block append, RequiresAction terminality.
1 parent 6cfae6d commit 4f0bc70

131 files changed

Lines changed: 17160 additions & 5873 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

MIGRATING.md

Lines changed: 270 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Migrating Rig
22

3-
This guide covers every breaking change from 0.30 through 0.41. Releases 0.36,
4-
0.37, 0.40 and 0.41 were the disruptive ones; 0.40 alone carried 31 breaking
5-
changes, and 0.37 renamed `rig-core`'s library target.
3+
This guide covers every breaking change from 0.30 through the unreleased changes
4+
after 0.41. Releases 0.36, 0.37, 0.40 and 0.41 were the disruptive ones; 0.40
5+
alone carried 31 breaking changes, and 0.37 renamed `rig-core`'s library
6+
target.
67

78
## Which sections apply to you
89

@@ -11,6 +12,7 @@ above it, in order. Each one is self-contained.
1112

1213
| You are on | Start at |
1314
| --- | --- |
15+
| 0.41 | [0.41 → next](#041--next) |
1416
| 0.40 | [0.40 → 0.41](#040--041) |
1517
| 0.39 | [0.39 → 0.40](#039--040) |
1618
| 0.38 | [0.38 → 0.39](#038--039) |
@@ -274,6 +276,267 @@ association.
274276

275277
---
276278

279+
## 0.41 → next
280+
281+
### Completion responses are concrete and normalized
282+
283+
`CompletionResponse<T>` is now `CompletionResponse`. The provider-native
284+
`raw_response` field is gone; the normalized response carries the metadata that
285+
callers actually reached into `raw_response` for:
286+
287+
```rust
288+
pub struct CompletionResponse {
289+
pub choice: OneOrMany<AssistantContent>,
290+
pub usage: Usage,
291+
pub message_id: Option<String>,
292+
pub response_id: Option<String>,
293+
pub finish_reason: Option<FinishReason>,
294+
pub provider: String,
295+
pub model: Option<String>,
296+
}
297+
```
298+
299+
| Before | After |
300+
| --- | --- |
301+
| `response.raw_response.model` | `response.model` |
302+
| provider stop/finish reason off `raw_response` | `response.finish_reason` |
303+
| provider/message identity off `raw_response` | `response.provider`, `response.message_id` |
304+
| response-scoped ID (`chatcmpl-*`, `responseId`, …) off `raw_response` | `response.response_id` |
305+
| a genuinely provider-specific field | `model.raw_completion(request).await?` |
306+
307+
`usage` is unchanged, including the rule that all-zero values mean the provider
308+
supplied no metrics. `model` is the identifier the *wire response* reported, not
309+
the one you requested — it is `None` when the provider omits it. `provider` is
310+
always populated, including on a response derived from a stream that ended
311+
before its terminal record.
312+
313+
`message_id` and `response_id` are distinct on purpose. `message_id` holds only
314+
identifiers the provider would recognize on a *replayed assistant message* (an
315+
OpenAI Responses output-message `msg_*` ID, an Anthropic `msg_*` ID); it is what
316+
agent history promotes into `Message::Assistant`'s `id`. `response_id` holds
317+
identifiers that name the response as a whole (an OpenAI chat `chatcmpl-*` ID, a
318+
Gemini `responseId`, a Cohere generation ID) — useful for logging and support,
319+
never echoed back to a provider. Code that previously read a chat provider's
320+
`message_id` should read `response_id` instead; for those providers
321+
`message_id` is now `None`.
322+
323+
`CompletionResponse` is `#[non_exhaustive]`; build it with
324+
`CompletionResponse::new(choice, usage, provider)` plus the `with_*` helpers.
325+
Use `with_finish_reason` / `with_optional_finish_reason` rather than assigning
326+
the field: the setters apply `FinishReason::reconcile_with_output`, which
327+
upgrades a reported `Stop` to `ToolCalls` when the turn actually carried tool
328+
calls. Several OpenAI-compatible gateways report `stop` on a tool-calling turn,
329+
so code branching on `ToolCalls` would otherwise miss the call.
330+
331+
Tests (or other code) holding a provider's raw response can re-derive the
332+
normalized fields via the additive `NormalizeCompletionResponse::normalize`
333+
bridge — the same conversion the provider's normalized path uses.
334+
335+
### Provider-native responses moved to `raw_completion` / `raw_stream`
336+
337+
Every built-in provider model exposes both:
338+
339+
```rust
340+
let native = model.raw_completion(request).await?; // the provider's own type
341+
let native_stream = model.raw_stream(request).await?; // RawStreamingResult<TheirTerminal>
342+
```
343+
344+
These share one request builder, transport call, parser, telemetry path, and
345+
error-preservation path with the normalized methods — the normalized method
346+
calls the raw one and maps the result, so there is still exactly one network
347+
request.
348+
349+
The trade: raw access now requires the concrete provider model rather than any
350+
`CompletionResponse`. Code that was generic over `CompletionModel` could never
351+
touch `raw_response` without a bound anyway, so in practice this affects code
352+
that had already committed to a provider.
353+
354+
### Normalized finish reasons
355+
356+
```rust
357+
pub enum FinishReason { Stop, Length, ToolCalls, ContentFilter, Other(String) }
358+
```
359+
360+
Unrecognized provider values are preserved verbatim in `Other` — in the
361+
provider's own spelling, so Gemini's `RECITATION` stays `RECITATION`. A provider
362+
adding a new terminal reason surfaces it rather than reading as a natural stop.
363+
`None` means the provider genuinely reported no reason.
364+
365+
### Ordinary streaming types no longer carry a response parameter
366+
367+
`StreamingCompletionResponse<R>`, `StreamingResult<R>`,
368+
`StreamedAssistantContent<R>`, and the downstream agent streaming types are
369+
concrete. Their terminal record is `StreamFinal`, which carries normalized
370+
usage, finish reason, provider, provider-reported model, message ID, and
371+
response ID.
372+
373+
A full `Reasoning` stream event supersedes prior `ReasoningDelta` events with
374+
the same reasoning `id` — UIs that render deltas incrementally should replace
375+
the accumulated text when the full block arrives, mirroring what the
376+
aggregated `choice` already does.
377+
378+
`GetTokenUsage` is deleted — read `StreamFinal::usage` (or
379+
`StreamingCompletionResponse::usage()`) directly. A stream that ends without a
380+
terminal record still reports `Usage::new()`, the documented zero sentinel.
381+
With `GetTokenUsage` gone, the telemetry helper
382+
`SpanCombinator::record_token_usage` takes `&Usage` instead of a
383+
`GetTokenUsage`-bounded generic.
384+
385+
A terminal record is now emitted only when the provider signaled genuine
386+
completion (its own end-of-response event). Previously, several provider
387+
streams synthesized a default-usage terminal record when the connection ended —
388+
including streams cut off mid-response. A stream that ends without a terminal
389+
record was truncated; treat the missing record as an incomplete turn, not a
390+
zero-usage success.
391+
392+
The agent surface enforces this: `agent.stream_prompt(...)` now yields
393+
`Err("provider stream ended without a terminal record; treating the turn as
394+
truncated")` for a stream the provider never confirmed complete, where it
395+
previously finished "successfully" with zero usage. If you see this error
396+
behind a flaky provider or proxy, the connection was cut mid-response — retry
397+
the turn rather than trusting the partial content.
398+
399+
`StreamingCompletionResponse::stream` takes the provider descriptor name first:
400+
401+
```rust
402+
StreamingCompletionResponse::stream(PROVIDER_NAME, normalized_stream)
403+
```
404+
405+
Provider implementations keep their native terminal type behind
406+
`RawStreamingResult<Native>` and map it once:
407+
408+
```rust
409+
let raw = self.raw_stream(request).await?;
410+
let normalized = rig_core::streaming::normalize_stream(raw, |native| {
411+
Ok(StreamFinal::new(PROVIDER_NAME, native.usage)
412+
.with_optional_finish_reason(map_finish_reason(native.finish_reason)))
413+
});
414+
Ok(StreamingCompletionResponse::stream(PROVIDER_NAME, normalized))
415+
```
416+
417+
`normalize_stream` applies the same `Stop``ToolCalls` reconciliation as the
418+
unary path, using the tool calls it actually saw on the stream.
419+
420+
`StreamingPrompt<M, R>` and `StreamingChat<M, R>` lost their `R` parameter:
421+
`StreamingPrompt<M>`, `StreamingChat<M>`.
422+
423+
### `CompletionModel` no longer owns response or construction types
424+
425+
Remove `Response`, `StreamingResponse`, `Client`, and `make` from custom
426+
implementations. A custom model implements only the normalized operations, and
427+
optionally `capabilities`:
428+
429+
```rust
430+
impl CompletionModel for MyModel {
431+
async fn completion(
432+
&self,
433+
request: CompletionRequest,
434+
) -> Result<CompletionResponse, CompletionError> { /* ... */ }
435+
436+
async fn stream(
437+
&self,
438+
request: CompletionRequest,
439+
) -> Result<StreamingCompletionResponse, CompletionError> { /* ... */ }
440+
}
441+
```
442+
443+
Construction is a separate, optional opt-in. `CompletionClient::completion_model`
444+
is now required and calls your model's own constructor:
445+
446+
```rust
447+
impl CompletionClient for MyClient {
448+
type CompletionModel = MyModel;
449+
450+
fn completion_model(&self, model: impl Into<String>) -> MyModel {
451+
MyModel::new(self.clone(), model.into())
452+
}
453+
}
454+
```
455+
456+
`client.completion_model(model)` and `client.agent(model)` are unchanged at call
457+
sites. A model type with no client at all is now expressible: implementing
458+
`CompletionModel` no longer drags in a client associated type.
459+
460+
A provider extension built on the generic `rig::client::Client<Ext, H>` cannot
461+
implement `CompletionClient` for that foreign type itself (orphan rule).
462+
Instead, implement the public `ConstructCompletionModel<Client<Ext, H>>` hook
463+
on your model type; the blanket `CompletionClient` implementation over
464+
`Client<Ext, H>` then supplies `completion_model` for you.
465+
466+
`CompletionModel` also no longer requires `Clone` — the trait demands only
467+
async service behavior, in the spirit of `tower::Service`; cloning or sharing
468+
a model is the caller's concern — and wrapping in an `Arc` genuinely works:
469+
`CompletionModel` is implemented for `Arc<M>` by forwarding, so `Arc<M>`
470+
passes through every generic API (`CompletionRequestBuilder`, agent
471+
construction), and `completion_request` on an `Arc` clones the `Arc`, never
472+
the model. Implementors can drop `Clone` derives they only carried for the
473+
bound (keeping them is harmless). Generic code that cloned a model through
474+
the trait must now bound `M: CompletionModel + Clone` explicitly or take the
475+
model by value. The `completion_request` convenience gates on `Self: Clone`
476+
individually; every built-in provider model, `Arc<M>`, and `ModelHandle`
477+
satisfy it, so call sites on concrete types compile unchanged.
478+
479+
`CompletionResponse::finish_reason` is now a private field with a
480+
`finish_reason()` getter: every write flows through `with_finish_reason` /
481+
`with_optional_finish_reason`, so the `Stop``ToolCalls` reconciliation can
482+
no longer be bypassed by direct assignment. Replace field reads with the
483+
getter call.
484+
485+
The identifier and model setters on both `CompletionResponse` and
486+
`StreamFinal` now treat an empty string as absent: gateways that echo `""`
487+
produce `None`, matching the streaming paths, and the rule lives in the
488+
setters rather than at provider call sites.
489+
490+
Both invariants also hold through `Deserialize`: the two types deserialize
491+
via a wire-shape mirror that funnels through `new(...)` and the setters, so
492+
a persisted `"finish_reason": "stop"` alongside a tool-call choice comes
493+
back as `ToolCalls` and a persisted `""` identifier comes back as `None`.
494+
The serialized wire format is unchanged.
495+
496+
Corrupt stream frames (payloads that are not valid JSON) are now surfaced as
497+
`Err` items on the stream instead of being logged and silently skipped; the
498+
stream keeps consuming, and a later genuine terminal still completes it.
499+
Valid-JSON events whose shape this client doesn't recognize are still skipped
500+
(with a warning) for forward compatibility with new provider event types.
501+
Consumers that drained to `None` see the same content as before plus any
502+
error items; consumers that stopped at the first `Err` should drain to
503+
`None` — see the emission-contract table on `StreamFinal`.
504+
505+
### Provider behavior is reported through capabilities
506+
507+
`CompletionModel::composes_native_output_with_tools()` is replaced by
508+
`CompletionModel::capabilities()`:
509+
510+
```rust
511+
fn capabilities(&self) -> ProviderCapabilities {
512+
ProviderCapabilities::default().with_native_output_tool_composition(true)
513+
}
514+
```
515+
516+
`ProviderCapabilities` is public and `#[non_exhaustive]`; start from `Default`
517+
or `ProviderCapabilities::new()` and enable what you support. Capabilities are
518+
plain data, so a runtime can snapshot them instead of holding a callback into
519+
the concrete model.
520+
521+
### Agents erase the model type at construction (runtime model swapping)
522+
523+
`Agent<M>`, `AgentBuilder<M>`, `AgentRunner<M>`, the prompt/stream request
524+
types, and `Extractor<M, T>` lost their model parameter: `AgentBuilder::new`
525+
takes any `CompletionModel + 'static` and erases it once into a concrete
526+
`ModelHandle` (which itself implements `CompletionModel`). Update type
527+
annotations by deleting the parameter — `Agent<openai::CompletionModel>`
528+
becomes `Agent`; `Extractor<M, T>` becomes `Extractor<T>`. Construction call
529+
sites are unchanged.
530+
531+
Because the stored model is a handle, it can now change at runtime:
532+
`Agent::set_model`, per-run `runner(...).using_model(...)`, or an
533+
`AgentHook::on_model_select` hook receiving `ModelSelection` (which sees the
534+
merged `RequestPatch` and the previous model, and may pick a different handle
535+
per model call). `CompletionModel::capabilities()` is captured by value when
536+
the handle is created.
537+
538+
---
539+
277540
## 0.40 → 0.41
278541

279542
### 1. The crate split
@@ -444,9 +707,10 @@ response. For intentionally hook-free transport, start from
444707
for custom drivers. It holds no configured model, tools, memory, or hooks and is
445708
not an alternate execution path for configured agents.
446709

447-
An `Agent`'s model is fixed and private. Former per-call `.model(...)` /
448-
`.model_opt(...)` users should retain the provider `CompletionModel` and use its
449-
raw request API, or construct a separate `Agent`.
710+
An `Agent`'s default model is set at construction. Per-run overrides now go
711+
through `runner(...).using_model(...)`, `Agent::set_model`, or a
712+
`ModelSelection` hook (see the "runtime model swapping" section for the
713+
current release).
450714

451715
`Extractor` now routes through the full hook lifecycle.
452716

crates/rig-agent/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
10+
### Added
11+
12+
- *(agent)* add opaque, cloneable `ModelHandle` values with by-value `ProviderCapabilities` snapshots, plus default replacement, per-run default override (`using_model`), and hook-driven per-call selection via `AgentHook::on_model_select`
13+
- *(agent)* add run-local extractor default-model overrides used across retries
14+
15+
### Changed
16+
17+
- *(agent)* [**breaking**] remove concrete model parameters from long-lived classic runtime types (`Agent`, `AgentBuilder` after `new()`, `AgentRunner`, prompt/stream requests, `Extractor`) — the typed model is erased once at construction; direct provider-model completion and streaming APIs remain typed
18+
- *(agent)* completion-call hooks now resolve before model selection: the merged `RequestPatch` is exposed on `ModelSelection::request_patch`, request preparation runs against the selected model's captured capabilities, and `ModelSelection::previous_model` reflects issued attempts only
19+
20+
- *(completion)* [**breaking**] normalize completion responses at the provider boundary — `CompletionResponse` and `StreamingCompletionResponse` are concrete, carry normalized `finish_reason`/`provider`/`model`/`message_id`, and every provider model exposes typed `raw_completion`/`raw_stream` escape hatches
21+
- *(completion)* add public `ProviderCapabilities`, replacing `CompletionModel::composes_native_output_with_tools`
22+
23+
### Removed
24+
25+
- *(completion)* [**breaking**] remove `CompletionModel::{Response, StreamingResponse, Client, make}`; model construction moves to the required `CompletionClient::completion_model`
26+
- *(completion)* [**breaking**] remove the `GetTokenUsage` trait — read `StreamFinal::usage`
27+
- *(completion)* [**breaking**] remove `CompletionResponse::raw_response` — use a provider model's `raw_completion`/`raw_stream`
28+
929
## [0.41.0](https://github.com/0xPlaygrounds/rig/compare/rig-agent-v0.0.0...rig-agent-v0.41.0) - 2026-07-28
1030

1131
### Added

crates/rig-agent/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ rmcp = { workspace = true, optional = true, features = ["client"] }
4747

4848
[dev-dependencies]
4949
anyhow = { workspace = true }
50+
# `cargo test -p rig-agent` needs rig-core's mock models even when the
51+
# `test-utils` feature is not requested explicitly.
52+
rig-core = { path = "../rig-core", features = ["test-utils"] }
5053
tokio = { workspace = true, features = ["full"] }
5154
tokio-test = { workspace = true }
5255
tracing-subscriber = { workspace = true, features = ["env-filter"] }

0 commit comments

Comments
 (0)