rig_core/providers/internal/adapter.rs
1//! The wire-adapter contract and its single-policy-site driver.
2//!
3//! Every streaming wire family is one [`WireAdapter`]: a sans-IO pair of pure
4//! functions — `classify` (delegating to a `wire.rs` classifier) and
5//! `interpret` (stateful event → canonical-grammar mapping). The generic
6//! [`run_wire_stream`] driver owns the *entire* frame-triage policy, so no
7//! adapter can hand-roll its own handling of unknown or corrupt frames:
8//!
9//! | classify | driver action |
10//! |---------------------------|----------------------------------------------|
11//! | [`WireEvent::Known`] | `adapter.interpret`, yield its outputs |
12//! | [`WireEvent::Unknown`] | `tracing::warn!` (metadata only), skip on |
13//! | | the semantic path, and yield the raw value |
14//! | | as [`RawStreamingChoice::Unknown`] (the |
15//! | | passthrough channel — never aggregated) |
16//! | [`WireEvent::Corrupt`] | in-band `Err` item, keep consuming |
17//! | transport `Err` | `Err` item, then end (truncation semantics — |
18//! | | no `finish` flush, no terminal record) |
19//!
20//! The trait is public so out-of-tree providers implement it and inherit the
21//! shared driver and policy instead of hand-rolling assemblers; like the
22//! erased-model precedent, an adapter is constructed once per stream and never
23//! stored as a generic.
24
25use std::borrow::Cow;
26
27use futures::{Stream, StreamExt};
28
29use super::wire::WireEvent;
30use crate::completion::CompletionError;
31use crate::streaming::{RawStreamingChoice, RawStreamingResult};
32use crate::wasm_compat::WasmCompatSend;
33
34/// One transport frame, after framing but before decoding.
35///
36/// The transport layer (SSE framer, NDJSON splitter, websocket reader) owns
37/// byte splitting and yields these; adapters never split bytes.
38#[derive(Debug, Clone)]
39pub enum WireFrame {
40 /// A decoded text payload — an SSE `data:` field or a ws message body.
41 Text(String),
42 /// A raw byte payload — an NDJSON line or a binary SDK frame.
43 Bytes(Vec<u8>),
44}
45
46impl WireFrame {
47 /// The frame payload as text (lossy for byte frames).
48 pub fn as_str(&self) -> Cow<'_, str> {
49 match self {
50 Self::Text(text) => Cow::Borrowed(text),
51 Self::Bytes(bytes) => String::from_utf8_lossy(bytes),
52 }
53 }
54}
55
56/// What one adapter step hands back to the driver.
57///
58/// `Err` items are data-level defects the adapter itself detects while
59/// assembling (e.g. accumulated tool-argument JSON that fails to parse);
60/// frame-level defects never reach `interpret` — the driver surfaces those
61/// from `classify` directly.
62pub type AdapterOutput<R> = Vec<Result<RawStreamingChoice<R>, CompletionError>>;
63
64/// One streaming wire family as a thin adapter onto the canonical grammar.
65///
66/// `classify` and `interpret` are sans-IO by construction: no transport
67/// handle, no async — pure `(state, event) → events` functions, testable by
68/// feeding events directly with no mock HTTP.
69///
70/// # Contract for implementors (in-tree and out-of-tree)
71///
72/// This trait is public so companion provider crates (rig-bedrock,
73/// rig-gemini-grpc, rig-candle) and out-of-tree providers implement it and
74/// inherit the shared [`run_wire_stream`] / [`run_wire_buffered`] drivers.
75/// An implementation must uphold:
76///
77/// - **Classify delegation**: [`WireAdapter::classify`] delegates to a
78/// `wire.rs` classifier — never raw serde — so decode-then-validate policy
79/// is stated once per wire family.
80/// - **Driver-owns-policy**: unknown/corrupt-frame handling belongs to the
81/// driver (module policy table); adapters contain no `match WireEvent`.
82/// - **Mandatory identity**: every `Reasoning`/`ReasoningDelta`,
83/// `ToolCallDelta`, and `TextStart` event carries a
84/// [`StreamPartId`](crate::streaming::StreamPartId) — the wire's own identity
85/// (`StreamPartId::Wire`) when it exists, else
86/// an identity minted via [`SyntheticIds`]
87/// (`StreamPartId::Minted`). Provenance
88/// travels in the type: a minted identity keys stream accumulation and
89/// structurally cannot become a durable provider handle or reach a request
90/// serializer, so no per-provider gate exists or is needed.
91/// - **Finish/flush obligations**: see [`WireAdapter::finish`] (EOF-only,
92/// never synthesizes a terminal) and
93/// [`WireAdapter::flush_before_terminal_error`] (fully-delivered content
94/// only, no terminal record).
95/// - **[`WireAdapter::is_finished`]**: `true` only after `interpret`
96/// consumed the wire's own in-band terminal failure, having pushed the
97/// flush-then-`Err` sequence itself.
98pub trait WireAdapter {
99 /// The transport frame this adapter classifies: [`WireFrame`] for byte
100 /// wires (SSE, NDJSON, websocket), the SDK's own event type for
101 /// typed-transport wires (bedrock's Converse events, gemini-grpc's
102 /// protobuf responses, candle's in-process generation events).
103 type Frame;
104 /// The wire's typed event, produced by the `wire.rs` classifier.
105 type Event;
106 /// The provider-native terminal record carried by
107 /// [`RawStreamingChoice::FinalResponse`].
108 type Response;
109
110 /// Decode + classify one transport frame. MUST delegate to a `wire.rs`
111 /// classifier (`classify_tagged_frame` / `classify_chat_completions_frame`
112 /// / `classify_untyped_line` / `classify_typed_event`) — never raw serde,
113 /// so the decode-then-validate policy cannot be re-derived per adapter.
114 fn classify(&self, frame: Self::Frame) -> WireEvent<Self::Event>;
115
116 /// Map one `Known` event to canonical grammar events. Stateful: index→id
117 /// maps, open-block state, id fabrication, and wire-quirk quarantine live
118 /// here — policy for unknown/corrupt frames does not (the driver owns it).
119 ///
120 /// Pushing a [`RawStreamingChoice::FinalResponse`] marks the provider's
121 /// genuine terminal; the driver stops consuming after yielding it.
122 fn interpret(&mut self, event: Self::Event, out: &mut AdapterOutput<Self::Response>);
123
124 /// End-of-stream flush on EOF without a terminal (close open blocks).
125 ///
126 /// Never runs after a transport error (truncation drops partials) or after
127 /// a terminal was interpreted. Must not synthesize a terminal record: EOF
128 /// without the provider's own end event is truncation, and a fabricated
129 /// terminal would read as a successfully completed turn. (A terminal the
130 /// provider *did* signal earlier — e.g. the chat-completions `[DONE]`
131 /// sentinel or a `finish_reason` chunk, whose usage trailer arrives later —
132 /// may be emitted here; that is deferral, not synthesis.)
133 fn finish(&mut self, out: &mut AdapterOutput<Self::Response>);
134
135 /// Flush content the provider fully delivered before a terminal error item
136 /// (a transport failure or an in-band provider error envelope) reaches the
137 /// consumer.
138 ///
139 /// Default: nothing — truncation drops partials. Wires that buffer
140 /// fully-delivered tool calls (the chat-completions compat family, the
141 /// Responses SSE loop) override this so a first-`Err`-stop consumer still
142 /// sees them. Must not push a terminal record.
143 fn flush_before_terminal_error(&mut self, _out: &mut AdapterOutput<Self::Response>) {}
144
145 /// Whether `interpret` consumed the wire's own in-band terminal failure.
146 ///
147 /// When true after an `interpret` call, the driver stops consuming without
148 /// running the EOF `finish` flush — the adapter has already pushed the
149 /// flush-then-`Err` sequence itself. Default: never.
150 fn is_finished(&self) -> bool {
151 false
152 }
153}
154
155/// One frame after [`triage_frame`]: a modeled event for `interpret`, or an
156/// unknown frame's raw payload for the passthrough channel.
157#[derive(Debug)]
158pub enum TriagedFrame<T> {
159 /// A modeled event, ready for [`WireAdapter::interpret`].
160 Event(T),
161 /// An unknown frame's raw payload. Already warned; the caller forwards it
162 /// as [`RawStreamingChoice::Unknown`] where the surface has a raw channel
163 /// (openai-agents' raw-event precedent), and never interprets it — the
164 /// semantic path skips it.
165 Unknown(crate::streaming::UnknownPayload),
166}
167
168/// Triage one classified frame under the shared policy table (see the module
169/// docs): `Known` passes through, `Unknown` is warned (structural metadata
170/// only) and handed back raw for the passthrough channel, `Corrupt` is a
171/// [`CompletionError::JsonError`].
172///
173/// This is [`run_wire_stream`]'s per-frame policy factored out for the
174/// non-stream surfaces that classify frames one at a time (the websocket
175/// pre-dispatch, the interactions typed-event stream), so they share the
176/// driver's table instead of restating it.
177pub fn triage_frame<T>(event: WireEvent<T>) -> Result<TriagedFrame<T>, CompletionError> {
178 match event {
179 WireEvent::Known(event) => Ok(TriagedFrame::Event(event)),
180 WireEvent::Unknown { event_type, value } => {
181 // Structural metadata only — see `warn_unmodeled`. The full
182 // payload survives on the `Unknown` raw passthrough channel;
183 // that channel IS the opt-in for consumers who want the content.
184 warn_unmodeled(&event_type, &value);
185 Ok(TriagedFrame::Unknown(value))
186 }
187 WireEvent::Corrupt(error) => Err(CompletionError::JsonError(error)),
188 }
189}
190
191/// Warn about an unmodeled wire payload with **structural metadata only** —
192/// its kind and serialized byte size, never the payload itself. Unmodeled
193/// frames and parts can carry model output or other sensitive provider
194/// data, which must not leak into production WARN logs; the one redaction
195/// policy lives here, used by the driver's Unknown arm and by adapters that
196/// skip an unmodeled part kind. `driver_adoption.rs` scans streaming
197/// modules for direct `warn!(?...)` payload captures, so bypassing this
198/// helper fails CI.
199pub fn warn_unmodeled(kind: &str, payload: &impl serde::Serialize) {
200 tracing::warn!(
201 kind,
202 payload_bytes = unknown_payload_bytes(payload),
203 "skipping unmodeled wire payload"
204 );
205}
206
207/// Serialized byte size of an unknown frame's payload, for the structural
208/// warn log (the log never carries the payload itself).
209fn unknown_payload_bytes(value: &impl serde::Serialize) -> u64 {
210 /// Counter sink: measures how many bytes serialization would write
211 /// without buffering them.
212 struct CountingWriter(u64);
213
214 impl std::io::Write for CountingWriter {
215 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
216 self.0 += buf.len() as u64;
217 Ok(buf.len())
218 }
219
220 fn flush(&mut self) -> std::io::Result<()> {
221 Ok(())
222 }
223 }
224
225 let mut counter = CountingWriter(0);
226 // A `Value` cannot fail to serialize; degrade to 0 rather than panic.
227 let _ = serde_json::to_writer(&mut counter, value);
228 counter.0
229}
230
231/// Drive one transport stream through an adapter under the shared policy.
232///
233/// This is the single policy site for every wire family (see the module table).
234/// Adapters contain no `match WireEvent`.
235pub fn run_wire_stream<A, S>(transport: S, mut adapter: A) -> RawStreamingResult<A::Response>
236where
237 A: WireAdapter + WasmCompatSend + 'static,
238 A::Frame: WasmCompatSend,
239 A::Event: WasmCompatSend,
240 A::Response: WasmCompatSend + 'static,
241 S: Stream<Item = Result<A::Frame, CompletionError>> + WasmCompatSend + 'static,
242{
243 Box::pin(async_stream::stream! {
244 let mut transport = Box::pin(transport);
245 let mut out: AdapterOutput<A::Response> = Vec::new();
246 // Debug-mode sequence laws over the raw adapter output: every
247 // conformance fixture and cassette replay checks what the adapter
248 // ACTUALLY emits, not just what accumulator fixtures spell.
249 // Compiled out of release builds.
250 #[cfg(any(test, debug_assertions))]
251 let mut sequence_laws = super::sequence_law::SequenceLaws::default();
252
253 while let Some(frame) = transport.next().await {
254 let frame = match frame {
255 Ok(frame) => frame,
256 Err(error) => {
257 // Truncation semantics: the error is the last item — no
258 // finish flush (partials drop), no terminal record. Content
259 // the provider fully delivered (an adapter's buffered tool
260 // calls) still flushes first, so a first-`Err`-stop
261 // consumer sees it.
262 adapter.flush_before_terminal_error(&mut out);
263 for item in out.drain(..) {
264 yield item;
265 }
266 yield Err(error);
267 return;
268 }
269 };
270
271 match triage_frame(adapter.classify(frame)) {
272 Ok(TriagedFrame::Event(event)) => adapter.interpret(event, &mut out),
273 // Skipped semantically, but surfaced verbatim on the raw
274 // passthrough channel so consumers who want unmodeled frames
275 // can observe them; aggregation never folds `Unknown` into
276 // the assistant choice.
277 Ok(TriagedFrame::Unknown(value)) => {
278 out.push(Ok(RawStreamingChoice::Unknown(value)));
279 }
280 Err(error) => {
281 yield Err(error);
282 }
283 }
284
285 #[cfg(any(test, debug_assertions))]
286 sequence_laws.check_batch(&out);
287
288 let saw_terminal = out
289 .iter()
290 .any(|item| matches!(item, Ok(RawStreamingChoice::FinalResponse(_))));
291 for item in out.drain(..) {
292 yield item;
293 }
294 if saw_terminal || adapter.is_finished() {
295 return;
296 }
297 }
298
299 adapter.finish(&mut out);
300 #[cfg(any(test, debug_assertions))]
301 sequence_laws.check_batch(&out);
302 for item in out.drain(..) {
303 yield item;
304 }
305 })
306}
307
308/// Drive an already-buffered frame sequence through an adapter under the
309/// no-stream policy.
310///
311/// This is the driver's buffered/unary mode, for replayed SSE bodies decoded
312/// after the fact (the Responses unary path, ChatGPT's replayed bodies). There
313/// is no stream to carry in-band `Err` items, so the policy table tightens —
314/// everything else is identical to [`run_wire_stream`]:
315///
316/// | classify | buffered action |
317/// |---------------------------|----------------------------------------------|
318/// | [`WireEvent::Known`] | `adapter.interpret`; an `Err` item it pushes |
319/// | | fails the whole operation |
320/// | [`WireEvent::Unknown`] | `tracing::warn!` + skip (a buffered result |
321/// | | is a finished completion — there is no |
322/// | | stream to carry the raw passthrough item) |
323/// | [`WireEvent::Corrupt`] | fail the whole operation — the alternative |
324/// | | is a successful-but-incomplete completion |
325///
326/// The `Corrupt` error's own message is surfaced verbatim (as a
327/// [`CompletionError::ResponseError`]), so a classifier can attach
328/// frame-naming context for the operation error.
329pub fn run_wire_buffered<A>(
330 frames: impl IntoIterator<Item = A::Frame>,
331 mut adapter: A,
332) -> Result<Vec<RawStreamingChoice<A::Response>>, CompletionError>
333where
334 A: WireAdapter,
335{
336 let mut out: AdapterOutput<A::Response> = Vec::new();
337 let mut choices = Vec::new();
338 // Same debug-mode sequence laws as `run_wire_stream` (see there).
339 #[cfg(any(test, debug_assertions))]
340 let mut sequence_laws = super::sequence_law::SequenceLaws::default();
341
342 for frame in frames {
343 match adapter.classify(frame) {
344 WireEvent::Known(event) => adapter.interpret(event, &mut out),
345 WireEvent::Unknown { event_type, value } => {
346 // Structural metadata only, matching [`triage_frame`]: unknown
347 // payloads can carry sensitive provider data and must not leak
348 // into WARN logs. (The stream driver additionally surfaces the
349 // full payload on the `Unknown` raw channel — the opt-in for
350 // consumers who want the content; a buffered result has no
351 // such channel, so here the payload is simply skipped.)
352 tracing::warn!(
353 event_type,
354 payload_bytes = unknown_payload_bytes(&value),
355 "skipping unrecognized stream event"
356 );
357 }
358 WireEvent::Corrupt(error) => {
359 return Err(CompletionError::ResponseError(error.to_string()));
360 }
361 }
362
363 #[cfg(any(test, debug_assertions))]
364 sequence_laws.check_batch(&out);
365
366 let saw_terminal = drain_buffered(&mut out, &mut choices)?;
367 if saw_terminal || adapter.is_finished() {
368 return Ok(choices);
369 }
370 }
371
372 adapter.finish(&mut out);
373 #[cfg(any(test, debug_assertions))]
374 sequence_laws.check_batch(&out);
375 drain_buffered(&mut out, &mut choices)?;
376 Ok(choices)
377}
378
379/// Move one buffered step's output into `choices`, failing the operation on
380/// the first `Err` item; reports whether a terminal record was appended.
381fn drain_buffered<R>(
382 out: &mut AdapterOutput<R>,
383 choices: &mut Vec<RawStreamingChoice<R>>,
384) -> Result<bool, CompletionError> {
385 let mut saw_terminal = false;
386 for item in out.drain(..) {
387 let choice = item?;
388 saw_terminal |= matches!(choice, RawStreamingChoice::FinalResponse(_));
389 choices.push(choice);
390 }
391 Ok(saw_terminal)
392}
393
394pub use crate::streaming::SyntheticIds;