Normalize completion responses at the provider boundary - #2256
Closed
gold-silver-copper wants to merge 5 commits into
Closed
Normalize completion responses at the provider boundary#2256gold-silver-copper wants to merge 5 commits into
gold-silver-copper wants to merge 5 commits into
Conversation
… 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.
…dary 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.
/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.
…dary 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This normalizes completion responses at the provider boundary so that
CompletionModeldescribes only what a model does, not what shape itsprovider happens to answer in.
CompletionModellosesResponse,StreamingResponse,Client, andmake.CompletionResponseandStreamingCompletionResponseare concrete.raw_responsefor:finish_reason,provider, the provider-reportedmodel, andmessage_id.inherent
raw_completion/raw_stream.CompletionClient::completion_model.ProviderCapabilitiesreplacescomposes_native_output_with_toolswith plaindata a runtime can snapshot.
Breaking API changes are intentional. This is the prerequisite for #2252 — the
associated types are exactly what prevents
Box<dyn CompletionModel>, so theyhave to go before an erased
ModelHandleis expressible. It does not modify#2252 or introduce any runtime model-selection machinery.
This is an independent implementation of the same prerequisite as #2254, not a
revision of it.
Core API shape
CompletionModelno longer drags in a client type, so a model with no client atall is now expressible — covered by a test in
client/completion.rs.Three details that are load-bearing
The
Stop→ToolCallsreconciliation lives in exactly one place.Several OpenAI-compatible gateways report a bare
stopon a turn that carriedtool calls.
FinishReason::reconcile_with_outputis the single implementation,and it is applied by
CompletionResponse::with_finish_reason/with_optional_finish_reasonon the unary path and bynormalize_stream— usingthe tool calls actually observed — on the streaming path. The
Optionsetterexists precisely so a provider holding an
Option<FinishReason>never has tochoose between ergonomics and correctness by assigning the field directly. Only
a natural
Stopis upgraded;Length,ContentFilter, andOtherdescribeterminations that remain true regardless of content.
A conversion over a shared wire type takes the provider name as an input.
NormalizeCompletionResponse::normalize(self, provider)is implemented by theOpenAI chat-completions payload that ~16 providers share. Hardcoding
"openai"there would mislabel every one of them, and a "placeholder the caller overwrites
afterwards" would be correct only by convention. Threading the name through the
conversion makes it impossible to forget.
This is a trait rather than
TryFrom<(&str, T)>for a concrete reason foundduring review: a tuple is not a local type, so
impl TryFrom<(&str, TheirType)> for CompletionResponseis rejected by the orphan rule in every crate exceptrig-core. The tuple form would have made external provider extensionsimpossible to write. Implementing the trait on a provider's own response type is
allowed anywhere, which is verified by the four companion crates plus a probe.
provideris known when a stream opens, not when it terminates.StreamingCompletionResponse::streamtakes the descriptor name as its firstargument, so a stream that errors or is cancelled before its terminal record
still names its provider. Every other missing value has a documented sentinel
(
Usage::new(),None); this one should not degrade to an empty string.Native-response escape hatches
Both
raw_completionandraw_streamare implemented for everycompletion-capable provider model:
GenericCompletionModel<Ext, H>, shared by Azure, DeepSeek, Doubleword, Groq, Hugging Face, Hyperbolic, Llamafile, MiniMax, Mira, Mistral, Moonshot, OpenRouter, Perplexity, Together, Xiaomi MiMo, Z.AI), OpenAI Responses, ChatGPT, Copilot (both routes), Gemini REST, Gemini Interactions, Cohere, Ollama, xAIraw_completionreturns the provider's own unary type.raw_streamreturnsRawStreamingResult<Native>, whose terminal item is the provider's own type —the normalized path maps it once with
normalize_stream. The normalized methodcalls the raw one, so there is exactly one network request either way, and both
share the request builder, transport, parser, telemetry and error-preservation
path.
Finish-reason normalization
stop;length|max_tokens;tool_calls|function_call;content_filterStop;Length;ToolCalls;ContentFiltercompleted;incomplete+max_output_tokens;incomplete+content_filter;failed|cancelledStop;Length;ContentFilter;Other(status)end_turn|stop_sequence;max_tokens;tool_use;refusalStop;Length;ToolCalls;ContentFilterCOMPLETE|STOP_SEQUENCE;MAX_TOKENS;TOOL_CALL;ERRORStop;Length;ToolCalls;Other("ERROR")STOP;MAX_TOKENS;SAFETY|BLOCKLIST|PROHIBITED_CONTENT|SPIIStop;Length;ContentFilterCOMPLETED;REQUIRES_ACTION;BUDGET_EXCEEDEDStop;ToolCalls;Lengthstop;lengthStop;Lengthend_turn|stop_sequence;max_tokens;tool_use;content_filtered|guardrail_intervenedStop;Length;ToolCalls;ContentFilterStop;LengthUnknown values are preserved verbatim in
Other(String), in the provider's ownspelling — Gemini's
RECITATIONstaysRECITATION, Ollama's operationalload/unloadstay themselves. Nothing is smoothed intoStop, and no variantis stringified through
Debug.Nonemeans the provider genuinely reported noreason (an in-flight Responses status, Gemini's
FINISH_REASON_UNSPECIFIED).Where reconciliation applies, a
Stopon a tool-calling turn becomesToolCalls— consistently on both the unary and streaming paths.Notes on the design
Construction hook. Rust's coherence rules rule out one blanket
CompletionClientimpl per provider family (they would all overlap onClient<Ext, H>), and a public bound such asFrom<(Client<Ext, H>, String)>would push a synthetic conversion into every provider model's public API. The
blanket impl instead uses a
pub(crate) trait ConstructCompletionModel, whichconstrains only rig's own generic client. Provider crates outside
rig-coreimplement
CompletionClientdirectly and never see it.Surviving associated types.
OpenAICompatibleProvider::{Response, StreamingUsage}andCompatibleStreamProfile::{Usage, Detail, FinalResponse}remain, because they name genuinely provider-native wire types at the provider's
own boundary. They do not appear in
CompletionModel, the agent, or anyordinary streaming type.
One known limitation. ChatGPT's
/responsesanswers with an SSE body evenfor a non-streaming request, and its terminal event sometimes carries an empty
output; the assistant content then exists only in the preceding events, whichCompletionModel::completionreassembles. So for that provider there is nopublic single-request way to obtain both the raw wire response and the
normalized one —
raw_streamis the full-fidelity escape hatch. This isdocumented on
ResponsesCompletionModel::raw_completion, and it cost oneassertion in
codex_behaviors::store_false_and_prompt_cache_fields_roundtrip(the namesake
store/prompt_cache_keyassertions are preserved; the assistanttext assertion it also carried is covered by
codex_sessions).Independent review
A four-lens adversarial review ran over the complete diff (correctness,
raw/normalized parity, streaming behavior, API leakage), with every finding
re-checked by a separate agent instructed to refute it. 38 findings were raised;
14 were refuted and 24 confirmed. All 24 are fixed in this branch. The ones worth
knowing about:
TryFrom<(&str, T)>, which theorphan rule makes unimplementable outside
rig-core— it would have silentlyblocked external provider extensions. Now a trait (see above).
normalize_streamcounted tool-call deltas toward theStop→ToolCallsupgrade while the unary path counts only completed calls, so a stream whose
tool call failed to assemble reported
ToolCallsover a choice with none. Averifier reproduced this with a real SSE payload. Both paths now count only
completed calls.
send_compatible_streaming_requestis public and stamped a hardcoded"openai"on every stream — the same placeholder hazard this change removeselsewhere. It now takes the descriptor name.
message_idon the unary path but not the streaming one.Both do now.
Two findings were deliberately not changed, because they are pre-existing at
the merge base and fixing them here would be unrelated behavior changes:
bedrock's
MaxTokensearly yield, and Z.AI reporting"zai"on its OpenAI routeversus
"z.ai"on its Anthropic route (both already fedgen_ai.provider.name).Known issues under active fix
A follow-up review found three P1s in this branch. All three are verified
against the code, and fixes are in progress on this branch — do not merge until
they land.
Response-scoped ids are replayed as assistant-message ids. OpenAI Chat's
chatcmpl-…reachesCompletionResponse::message_id(
openai/completion/mod.rs:1220), is promoted intoMessage::Assistant { id }(
rig-agent/src/agent/run/mod.rs:540), and is serialized by the Responsesconverter as an assistant output-message id
(
openai/responses_api/mod.rs:583-612). This gets worse under refactor(agent): support hook-driven runtime model routing #2252, whereswitching provider between turns is the point. Fix: split
response_idfrommessage_id, so only genuine assistant-message ids are replayable.The construction hook is
pub(crate). The blanketimpl CompletionClient for Client<Ext, H>requiresConstructCompletionModel, which downstream crates cannot name; andimpl CompletionClient for Client<TheirExt, H>is E0117 (verified with aprobe). So an extension riding rig's generic client is locked out — the same
class of break this PR fixed for the normalization trait. Fix: make it public
and documented, with downstream compile coverage.
Truncated streams still synthesize successful terminal records. The
terminated_with_error/stream_failedflags cover explicit errors but nottruncation, so anthropic, cohere, Gemini REST and Gemini gRPC all yield a
terminal record at clean EOF; gRPC fabricates one outright
(
final_resp.or(last_resp).unwrap_or_default()). Anthropic's malformed-SSEbranch also yields an error without setting the flag or breaking, so one
stream can emit both an error and a success record — contradicting an
invariant stated above. Fix: guard each stream on the provider's genuine
terminal event, with a regression test per provider.
Verification
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features— cleancargo test --workspace --all-features -- --test-threads=1RUSTDOCFLAGS='-D warnings' cargo doc -p rig-core -p rig-agent -p rig --no-depscargo check --target wasm32-unknown-unknown -p rig-core -p rig-agent -p rigcargo check --workspace --all-targets --all-features— zero errors, zero warningsgit diff <merge-base>..HEAD -- tests/cassettesis empty. Requestconstruction did not change, so no fixture was re-recorded and no request
matcher moved.