Skip to main content

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;