Skip to main content

hotl_provider/
lib.rs

1//! L2 — the provider seam.
2//!
3//! `stream()` is the one required method. Events carry block structure
4//! (review Arch #6): every delta names its block index, and the provider —
5//! the only layer that understands its own wire format — assembles the final
6//! verbatim assistant blocks and hands them over in `Completed`. The engine
7//! never demuxes provider wire formats.
8//!
9//! Native (Send) variants are authoritative for M0; the `?Send` browser twins
10//! are derived at the gated milestone (rust-implementation §Key trait signatures).
11
12use std::collections::VecDeque;
13use std::sync::{Arc, Mutex};
14
15use futures_util::stream::BoxStream;
16use hotl_types::{Item, StopReason, TokenUsage};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20/// The three bounds every HTTP provider applies, in one place so the two
21/// dialect crates cannot drift.
22///
23/// A single request-level timeout is deliberately *not* used: it would bound
24/// the response body, and the response body of a streaming sample is the whole
25/// turn. Each bound below covers one place a request can actually stop making
26/// progress.
27pub mod timeouts {
28    use std::time::Duration;
29
30    /// TCP + TLS handshake. A stalled handshake is the fastest-failing case
31    /// and needs no generosity.
32    pub const CONNECT: Duration = Duration::from_secs(10);
33
34    /// Request sent, response headers not yet received. Generous: a local
35    /// server loading model weights can delay headers by a minute.
36    pub const HEADERS: Duration = Duration::from_secs(120);
37
38    /// Maximum gap between two SSE chunks once the stream is open. Long
39    /// enough for extended thinking between pings, short enough that a dead
40    /// connection is noticed within a coffee break.
41    pub const STREAM_IDLE: Duration = Duration::from_secs(300);
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ToolDef {
46    pub name: String,
47    pub description: String,
48    pub input_schema: Value,
49}
50
51#[derive(Debug, Clone)]
52pub struct SamplingRequest {
53    pub model: String,
54    pub max_tokens: u32,
55    /// Byte-stable owner system prompt (L6 discipline).
56    pub system: Arc<str>,
57    /// The **durable** projection only — exactly the items the session log
58    /// carries. Byte-stable between supersede events, which is what makes it
59    /// the one region a cache breakpoint may be placed in (L6 discipline).
60    pub items: Arc<Vec<Item>>,
61    /// Ephemeral per-sample suffix (the `<todos>` reminder today): regenerated
62    /// on every read of the projection head, never committed, and serialized
63    /// AFTER every cache marker (and before MOIM). Splitting it from `items`
64    /// is what makes "a marker on ephemeral content" unrepresentable rather
65    /// than merely avoided — a serializer has no ephemeral item to pick.
66    pub ephemeral_tail: Arc<Vec<Item>>,
67    pub tools: Arc<[ToolDef]>,
68    /// Adaptive thinking on models that support it.
69    pub thinking: bool,
70    /// Cache-breakpoint placement for explicit-cache providers.
71    pub cache: CachePolicy,
72    /// MOIM (M2): ephemeral per-turn context, sent as a trailing user block
73    /// after the cache marker AND after [`Self::ephemeral_tail`] — always the
74    /// last thing on the wire. Never persisted; it exists only on the wire.
75    pub turn_context: Option<String>,
76}
77
78/// How long a cache entry a breakpoint writes stays readable. Anthropic's two
79/// offered lifetimes; the wire spelling is the serializer's business.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum CacheTtl {
82    FiveMinutes,
83    OneHour,
84}
85
86/// What a provider may do with cache breakpoints for this request.
87///
88/// A policy, not a hint: `Off` means a serializer emits no `cache_control` at
89/// all, and only the byte-stable prefix — never [`SamplingRequest::items`]'s
90/// ephemeral companion — is ever eligible under `Static`.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CachePolicy {
93    /// No explicit markers: implicit-caching providers (OpenAI), the
94    /// compaction summarize call, and fixtures.
95    Off,
96    /// Explicit marker placement. `prefix_ttl` is the lifetime the *prefix*
97    /// breakpoints (and the rolling anchors — `cache_plan::Plan::anchors`)
98    /// ask for; the serializer's LATEST marker always renders plain
99    /// regardless of `prefix_ttl` (its segment is rewritten every sample, so
100    /// the longer-lived write premium there recurs per turn and buys
101    /// nothing — see `hotl-provider-anthropic`'s marker sites).
102    Static { prefix_ttl: CacheTtl },
103}
104
105impl CachePolicy {
106    /// Whether this request wants explicit breakpoints at all. The one
107    /// question serializers ask before consulting `prefix_ttl` for the
108    /// per-marker lifetime.
109    pub fn marks_breakpoints(self) -> bool {
110        matches!(self, Self::Static { .. })
111    }
112}
113
114/// The unified, channel-tagged, block-structured event enum.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(tag = "event", rename_all = "snake_case")]
117pub enum StreamEvent {
118    Started,
119    BlockStart {
120        index: usize,
121        kind: String,
122    },
123    TextDelta {
124        index: usize,
125        text: String,
126    },
127    ThinkingDelta {
128        index: usize,
129        text: String,
130    },
131    ToolInputDelta {
132        index: usize,
133        json: String,
134    },
135    BlockEnd {
136        index: usize,
137    },
138    Retrying {
139        attempt: u32,
140        reason: String,
141    },
142    /// Terminal event: the provider-assembled verbatim assistant blocks
143    /// (echo these back on the next request — replay-safe by construction).
144    Completed {
145        stop: StopReason,
146        usage: TokenUsage,
147        blocks: Vec<Value>,
148    },
149}
150
151/// `Clone` so a scripted stream can hand its script back when it is
152/// abandoned (see [`ScriptedProvider`]); every variant is plain data.
153#[derive(Debug, Clone, thiserror::Error)]
154pub enum ProviderError {
155    #[error("authentication failed: {0}")]
156    Auth(String),
157    #[error("HTTP {status}: {message}")]
158    Http {
159        status: u16,
160        message: String,
161        retry_after: Option<u64>,
162    },
163    #[error("transport error: {0}")]
164    Transport(String),
165    #[error("stream parse error: {0}")]
166    Parse(String),
167}
168
169pub trait Provider: Send + Sync {
170    fn stream(
171        &self,
172        req: SamplingRequest,
173    ) -> BoxStream<'static, Result<StreamEvent, ProviderError>>;
174
175    /// Pre-warm the connection pool ahead of the first real sample (§S3.2:
176    /// typing-time connection arming). No-op by default — only a provider
177    /// with a real pooled connection to warm overrides it (via
178    /// [`Warmable`]); a scripted/test provider has nothing to arm.
179    fn arm(&self) -> ArmGuard {
180        ArmGuard::noop()
181    }
182}
183
184/// A provider whose connection pool can be pre-warmed. Implemented by the
185/// HTTP dialect crates (`hotl-provider-anthropic`, `hotl-provider-openai`).
186/// `Provider::arm` is the dynamic-dispatch entry point call sites use
187/// (`Arc<dyn Provider>` erases the concrete type long before a trigger
188/// fires); this trait names the capability and lets each dialect crate
189/// implement it against its own `reqwest::Client` without this crate having
190/// to depend on `reqwest` itself (this crate stays wasm32-buildable — see
191/// the `tokio` dependency comment above).
192pub trait Warmable {
193    fn arm(&self) -> ArmGuard;
194}
195
196/// RAII handle for a connection warm-up. Dropping it cancels any warm
197/// request still in flight; [`ArmGuard::noop`] (nothing to warm, or a
198/// re-arm while already armed) holds and cancels nothing.
199///
200/// Deliberately generic over *any* cancel callback rather than naming
201/// `tokio::task::JoinHandle` directly: this crate has no `rt` feature and no
202/// `reqwest` dependency (wasm32-buildable), so the dialect crates hand in a
203/// closure that aborts their own real task.
204///
205/// `#[must_use]`: a bare `provider.arm();` statement drops the guard at the
206/// end of that statement, immediately cancelling the very request it just
207/// started — the opposite of the caller's intent. Bind it (even to `_name`)
208/// or call [`ArmGuard::detach`] explicitly.
209#[must_use]
210pub struct ArmGuard {
211    cancel: Option<Box<dyn FnOnce() + Send>>,
212}
213
214impl ArmGuard {
215    /// Nothing to warm — holds and cancels nothing on drop.
216    pub fn noop() -> Self {
217        Self { cancel: None }
218    }
219
220    /// Wrap a cancel callback: `drop` invokes it exactly once.
221    pub fn new(cancel: impl FnOnce() + Send + 'static) -> Self {
222        Self {
223            cancel: Some(Box::new(cancel)),
224        }
225    }
226
227    /// Let the warm task run to completion on its own: this guard no longer
228    /// cancels it on drop. For trigger sites with no session-scoped owner to
229    /// hold the guard across — safe because the underlying task is
230    /// short-lived by construction (bounded by its own internal timeout).
231    pub fn detach(mut self) {
232        self.cancel = None;
233    }
234}
235
236impl Drop for ArmGuard {
237    fn drop(&mut self) {
238        if let Some(cancel) = self.cancel.take() {
239            cancel();
240        }
241    }
242}
243
244#[cfg(test)]
245mod arm_guard_tests {
246    use super::*;
247    use std::sync::atomic::{AtomicBool, Ordering};
248
249    /// The core RAII contract: dropping an armed guard cancels it.
250    #[test]
251    fn drop_invokes_the_cancel_callback() {
252        let called = Arc::new(AtomicBool::new(false));
253        let flag = called.clone();
254        let guard = ArmGuard::new(move || flag.store(true, Ordering::SeqCst));
255        assert!(!called.load(Ordering::SeqCst));
256        drop(guard);
257        assert!(called.load(Ordering::SeqCst));
258    }
259
260    /// Nothing to warm means nothing happens on drop — and, just as
261    /// important, dropping it must not panic.
262    #[test]
263    fn noop_cancels_nothing() {
264        drop(ArmGuard::noop());
265    }
266
267    /// `detach` is the escape hatch for call sites with no natural owner:
268    /// the task is left to finish (or hit its own timeout) rather than being
269    /// cancelled when the guard goes out of scope.
270    #[test]
271    fn detach_suppresses_the_cancel_callback() {
272        let called = Arc::new(AtomicBool::new(false));
273        let flag = called.clone();
274        let guard = ArmGuard::new(move || flag.store(true, Ordering::SeqCst));
275        guard.detach();
276        assert!(!called.load(Ordering::SeqCst));
277    }
278
279    /// `Provider::arm`'s default: a provider with nothing to warm (every
280    /// scripted/fake provider in the workspace) is armable without error —
281    /// callers never need to special-case "does this provider support
282    /// arming".
283    #[test]
284    fn provider_default_arm_is_a_noop() {
285        let provider = ScriptedProvider::new(vec![]);
286        // Must not panic; dropping immediately must not either.
287        drop(provider.arm());
288    }
289}
290
291/// The honest "second impl" (D9): a scripted provider driving the real engine
292/// in tests. Each `stream()` call pops the next script.
293pub struct ScriptedProvider {
294    /// `Arc` so a handed-out stream can give its script back on drop —
295    /// see [`ScriptedStream`].
296    scripts: ScriptQueue,
297    /// Every request the engine made, for test assertions on what the model saw.
298    requests: Mutex<Vec<SamplingRequest>>,
299}
300
301/// One sample's worth of scripted events.
302type Script = Vec<Result<StreamEvent, ProviderError>>;
303/// The scripts a [`ScriptedProvider`] has left to hand out, shared with
304/// every stream it hands out so an abandoned one can give its script back.
305type ScriptQueue = Arc<Mutex<VecDeque<Script>>>;
306
307/// One scripted sample, in flight.
308///
309/// **A stream abandoned before end-of-stream returns its script** to the
310/// front of the queue. That is what makes optimistic dispatch (S2c) testable:
311/// a speculative sample cancelled on a mispredict must not eat the reply the
312/// sequential rebuild is about to ask for. A stream the consumer drove to
313/// `None` restores nothing — that script was really consumed — so every
314/// ordinary scenario is unaffected, and an engine that abandons a stream
315/// mid-flight (a cancelled turn, a mispredict) leaves the queue exactly as
316/// it found it.
317struct ScriptedStream {
318    script: Script,
319    pos: usize,
320    /// Set when `poll_next` has answered `None`: the script is spent.
321    exhausted: bool,
322    scripts: ScriptQueue,
323}
324
325impl futures_util::Stream for ScriptedStream {
326    type Item = Result<StreamEvent, ProviderError>;
327
328    fn poll_next(
329        self: std::pin::Pin<&mut Self>,
330        _cx: &mut std::task::Context<'_>,
331    ) -> std::task::Poll<Option<Self::Item>> {
332        let this = self.get_mut();
333        match this.script.get(this.pos) {
334            Some(event) => {
335                this.pos += 1;
336                std::task::Poll::Ready(Some(event.clone()))
337            }
338            None => {
339                this.exhausted = true;
340                std::task::Poll::Ready(None)
341            }
342        }
343    }
344}
345
346impl Drop for ScriptedStream {
347    fn drop(&mut self) {
348        if self.exhausted {
349            return;
350        }
351        self.scripts
352            .lock()
353            .expect("scripted provider mutex")
354            .push_front(std::mem::take(&mut self.script));
355    }
356}
357
358impl ScriptedProvider {
359    pub fn new(scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>) -> Self {
360        Self {
361            scripts: Arc::new(Mutex::new(scripts.into())),
362            requests: Mutex::new(Vec::new()),
363        }
364    }
365
366    /// Every captured request. Cheap since a request's history/tools are
367    /// shared (`Arc`) — the clone copies pointers, not items.
368    pub fn requests(&self) -> Vec<SamplingRequest> {
369        self.requests.lock().expect("requests mutex").clone()
370    }
371
372    /// The most recent request, if any.
373    pub fn last_request(&self) -> Option<SamplingRequest> {
374        self.requests
375            .lock()
376            .expect("requests mutex")
377            .last()
378            .cloned()
379    }
380
381    pub fn request_count(&self) -> usize {
382        self.requests.lock().expect("requests mutex").len()
383    }
384
385    /// Append a script after construction (tests that need the harness's
386    /// paths before the scripts can be written).
387    pub fn push_script(&self, script: Vec<Result<StreamEvent, ProviderError>>) {
388        self.scripts
389            .lock()
390            .expect("scripted provider mutex")
391            .push_back(script);
392    }
393
394    /// Convenience: a one-sample script that answers with plain text.
395    pub fn text_reply(text: &str) -> Vec<Result<StreamEvent, ProviderError>> {
396        vec![
397            Ok(StreamEvent::Started),
398            Ok(StreamEvent::BlockStart {
399                index: 0,
400                kind: "text".into(),
401            }),
402            Ok(StreamEvent::TextDelta {
403                index: 0,
404                text: text.into(),
405            }),
406            Ok(StreamEvent::BlockEnd { index: 0 }),
407            Ok(StreamEvent::Completed {
408                stop: StopReason::EndTurn,
409                usage: TokenUsage {
410                    input_tokens: 10,
411                    output_tokens: 5,
412                    ..Default::default()
413                },
414                blocks: vec![serde_json::json!({"type": "text", "text": text})],
415            }),
416        ]
417    }
418
419    /// Convenience: a sample that calls one tool.
420    pub fn tool_call(
421        id: &str,
422        name: &str,
423        input: Value,
424    ) -> Vec<Result<StreamEvent, ProviderError>> {
425        let block = serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input});
426        vec![
427            Ok(StreamEvent::Started),
428            Ok(StreamEvent::BlockStart {
429                index: 0,
430                kind: "tool_use".into(),
431            }),
432            Ok(StreamEvent::BlockEnd { index: 0 }),
433            Ok(StreamEvent::Completed {
434                stop: StopReason::ToolUse,
435                usage: TokenUsage {
436                    input_tokens: 10,
437                    output_tokens: 8,
438                    ..Default::default()
439                },
440                blocks: vec![block],
441            }),
442        ]
443    }
444}
445
446impl Provider for ScriptedProvider {
447    fn stream(
448        &self,
449        req: SamplingRequest,
450    ) -> BoxStream<'static, Result<StreamEvent, ProviderError>> {
451        self.requests.lock().expect("requests mutex").push(req);
452        let script = self
453            .scripts
454            .lock()
455            .expect("scripted provider mutex")
456            .pop_front()
457            .unwrap_or_else(|| {
458                vec![Err(ProviderError::Transport(
459                    "scripted provider exhausted".into(),
460                ))]
461            });
462        Box::pin(ScriptedStream {
463            script,
464            pos: 0,
465            exhausted: false,
466            scripts: Arc::clone(&self.scripts),
467        })
468    }
469}
470
471/// Normalize a configured base URL to one ending in `/v1`.
472///
473/// Two spellings are in the wild and users copy whichever their endpoint's
474/// docs show: hotl's own convention (and the OpenAI provider's) puts the
475/// version in the base, while bridges document the bare origin because
476/// official SDKs append the whole versioned path themselves. Everything that
477/// builds a URL from a configured base goes through here, so the provider and
478/// `hotl doctor` can never disagree about where an endpoint lives.
479pub fn v1_base(base: &str) -> String {
480    let base = base.trim_end_matches('/');
481    if base.ends_with("/v1") {
482        base.to_string()
483    } else {
484        format!("{base}/v1")
485    }
486}
487
488#[cfg(test)]
489mod base_url_tests {
490    use super::v1_base;
491
492    #[test]
493    fn both_spellings_and_trailing_slashes_resolve_alike() {
494        for input in [
495            "http://127.0.0.1:3456",
496            "http://127.0.0.1:3456/",
497            "http://127.0.0.1:3456/v1",
498            "http://127.0.0.1:3456/v1/",
499        ] {
500            assert_eq!(v1_base(input), "http://127.0.0.1:3456/v1", "input: {input}");
501        }
502    }
503}
504
505/// SSE parsing shared by HTTP providers (WHATWG server-sent events).
506///
507/// Chunks split mid-line and mid-UTF-8 code point, so bytes are buffered, not
508/// lossily decoded per chunk. Fields accumulate until a blank line dispatches
509/// the event; consecutive `data:` fields join with `\n`.
510///
511/// INVARIANT: no input can make this allocate without bound — a line is capped
512/// at [`SSE_MAX_BUFFER`] and an event at [`SSE_MAX_EVENT`]. Enforced by
513/// `sse_parser_tests::over_cap_input_is_a_parse_error_not_an_oom`.
514#[derive(Default)]
515pub struct SseParser {
516    buf: Vec<u8>,
517    /// `data:` field values for the event currently accumulating.
518    data: Vec<String>,
519    data_len: usize,
520}
521
522/// A stream that never sends a newline must not buffer without bound.
523pub const SSE_MAX_BUFFER: usize = 1024 * 1024;
524/// Nor may one that sends endless `data:` lines and never a blank line.
525pub const SSE_MAX_EVENT: usize = 4 * 1024 * 1024;
526
527impl SseParser {
528    /// Feed a chunk; returns the payloads of every event completed by it
529    /// (`[DONE]` filtered).
530    pub fn feed(&mut self, chunk: &[u8]) -> Result<Vec<String>, ProviderError> {
531        self.buf.extend_from_slice(chunk);
532        let mut out = Vec::new();
533        let mut start = 0;
534        while let Some(pos) = self.buf[start..].iter().position(|&b| b == b'\n') {
535            let line = String::from_utf8_lossy(&self.buf[start..start + pos])
536                .trim_end_matches('\r')
537                .to_string();
538            start += pos + 1;
539            self.line(&line, &mut out)?;
540        }
541        self.buf.drain(..start);
542        if self.buf.len() > SSE_MAX_BUFFER {
543            return Err(ProviderError::Parse(format!(
544                "SSE line exceeded {SSE_MAX_BUFFER} bytes without a newline"
545            )));
546        }
547        Ok(out)
548    }
549
550    /// End-of-stream flush: a final line with no trailing newline, and an
551    /// event never terminated by a blank line, are both real (servers close
552    /// the socket after the last `data:`). Dropping them loses the terminal
553    /// event and turns a complete response into a parse error.
554    pub fn finish(&mut self) -> Result<Vec<String>, ProviderError> {
555        let mut out = Vec::new();
556        if !self.buf.is_empty() {
557            let tail = std::mem::take(&mut self.buf);
558            let line = String::from_utf8_lossy(&tail)
559                .trim_end_matches('\r')
560                .to_string();
561            self.line(&line, &mut out)?;
562        }
563        self.dispatch(&mut out);
564        Ok(out)
565    }
566
567    fn line(&mut self, line: &str, out: &mut Vec<String>) -> Result<(), ProviderError> {
568        if line.is_empty() {
569            self.dispatch(out);
570            return Ok(());
571        }
572        if line.starts_with(':') {
573            return Ok(()); // comment / keep-alive
574        }
575        // `event:`, `id:`, `retry:` carry nothing this seam uses; both
576        // dialects put the whole event in `data`.
577        let Some(value) = line.strip_prefix("data:") else {
578            return Ok(());
579        };
580        // Spec: strip exactly one leading space, not all whitespace.
581        let value = value.strip_prefix(' ').unwrap_or(value);
582        self.data_len += value.len() + 1;
583        if self.data_len > SSE_MAX_EVENT {
584            return Err(ProviderError::Parse(format!(
585                "SSE event exceeded {SSE_MAX_EVENT} bytes without a blank line"
586            )));
587        }
588        self.data.push(value.to_string());
589        Ok(())
590    }
591
592    fn dispatch(&mut self, out: &mut Vec<String>) {
593        self.data_len = 0;
594        if self.data.is_empty() {
595            return;
596        }
597        let payload = std::mem::take(&mut self.data).join("\n");
598        if !payload.is_empty() && payload != "[DONE]" {
599            out.push(payload);
600        }
601    }
602}
603
604#[cfg(test)]
605mod sse_parser_tests {
606    use super::*;
607
608    /// INVARIANT: consecutive `data:` fields in one event are joined with a
609    /// newline and dispatched at the blank line (WHATWG SSE). A payload split
610    /// across two `data:` lines is legal and must parse as one JSON document.
611    #[test]
612    fn multi_line_data_fields_are_joined() {
613        let mut p = SseParser::default();
614        let out = p
615            .feed(b"event: x\ndata: {\"a\":\ndata: 1}\n\ndata: {\"b\":2}\n\n")
616            .unwrap();
617        assert_eq!(
618            out,
619            vec!["{\"a\":\n1}".to_string(), "{\"b\":2}".to_string()]
620        );
621        // Both are valid JSON, which is the whole point.
622        for payload in &out {
623            serde_json::from_str::<serde_json::Value>(payload).expect(payload);
624        }
625    }
626
627    /// INVARIANT: a final event with no trailing newline is delivered, not
628    /// dropped. Servers close the socket after the last line all the time.
629    #[test]
630    fn the_unterminated_final_line_is_flushed() {
631        let mut p = SseParser::default();
632        assert!(p
633            .feed(b"data: {\"type\":\"message_stop\"}")
634            .unwrap()
635            .is_empty());
636        assert_eq!(p.finish().unwrap(), vec!["{\"type\":\"message_stop\"}"]);
637    }
638
639    /// An event terminated by end-of-stream rather than a blank line is also
640    /// flushed — same failure mode, one newline later.
641    #[test]
642    fn a_trailing_event_without_a_blank_line_is_flushed() {
643        let mut p = SseParser::default();
644        assert!(p.feed(b"data: {\"n\":1}\n").unwrap().is_empty());
645        assert_eq!(p.finish().unwrap(), vec!["{\"n\":1}"]);
646    }
647
648    /// Comments (`:` keep-alives) and non-`data` fields are ignored, and
649    /// `[DONE]` never reaches an assembler.
650    #[test]
651    fn comments_other_fields_and_done_are_filtered() {
652        let mut p = SseParser::default();
653        let out = p
654            .feed(b": keepalive\nevent: ping\nid: 7\nretry: 100\ndata: [DONE]\n\ndata: {}\n\n")
655            .unwrap();
656        assert_eq!(out, vec!["{}".to_string()]);
657    }
658
659    /// INVARIANT: a stream that never sends a newline, and one that never
660    /// sends a blank line, are both bounded. Neither may grow without limit.
661    #[test]
662    fn over_cap_input_is_a_parse_error_not_an_oom() {
663        let mut p = SseParser::default();
664        let big = vec![b'x'; SSE_MAX_BUFFER + 1];
665        assert!(matches!(p.feed(&big), Err(ProviderError::Parse(_))));
666
667        let mut q = SseParser::default();
668        let line = format!("data: {}\n", "y".repeat(64 * 1024));
669        let err = loop {
670            if let Err(e) = q.feed(line.as_bytes()) {
671                break e;
672            }
673        };
674        assert!(matches!(err, ProviderError::Parse(_)), "{err:?}");
675    }
676
677    /// Reassembly across arbitrary chunk boundaries still holds, including
678    /// mid-UTF-8 splits.
679    #[test]
680    fn chunk_boundaries_and_utf8_splits_survive() {
681        let wire = "data: {\"t\":\"héllo → wörld\"}\n\n".as_bytes();
682        let mut p = SseParser::default();
683        let mut out = Vec::new();
684        for c in wire.chunks(3) {
685            out.extend(p.feed(c).unwrap());
686        }
687        out.extend(p.finish().unwrap());
688        assert_eq!(out.len(), 1);
689        let v: serde_json::Value = serde_json::from_str(&out[0]).unwrap();
690        assert_eq!(v["t"], "héllo → wörld");
691    }
692}
693
694/// Pure-data retry classification (RELIABILITY.md — never regex
695/// on prose). Both HTTP providers consult this; budgets reset per sample.
696pub mod retry {
697    use super::ProviderError;
698    use std::time::Duration;
699
700    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
701    pub enum Decision {
702        /// Wait this long, then retry. The value is the *deterministic* base;
703        /// apply [`with_jitter`] at the sleep site.
704        Retry { delay: Duration },
705        /// Not recoverable by retrying (auth, parse, client errors).
706        Fatal,
707    }
708
709    /// Five attempts: four retries at base 1/2/4/8s. Three was thin for
710    /// `overloaded_error` (529), the case this loop primarily exists for.
711    pub const MAX_ATTEMPTS: u32 = 5;
712
713    /// No single backoff exceeds this, however large `Retry-After` is. A
714    /// server answering `Retry-After: 3600` must not sleep the session for an
715    /// hour with only Ctrl-C as an exit (T2-16).
716    pub const RETRY_AFTER_CAP: Duration = Duration::from_secs(60);
717
718    /// `attempt` is 1-based (the attempt that just failed). Deterministic and
719    /// jitter-free on purpose, so the policy is exactly assertable.
720    pub fn classify(err: &ProviderError, attempt: u32) -> Decision {
721        if attempt >= MAX_ATTEMPTS {
722            return Decision::Fatal;
723        }
724        let backoff = Duration::from_secs(1u64 << (attempt - 1));
725        match err {
726            ProviderError::Http {
727                status,
728                retry_after,
729                ..
730            } if *status == 429 || *status >= 500 => Decision::Retry {
731                delay: retry_after
732                    .map(Duration::from_secs)
733                    .unwrap_or(backoff)
734                    .min(RETRY_AFTER_CAP),
735            },
736            ProviderError::Transport(_) => Decision::Retry {
737                delay: backoff.min(RETRY_AFTER_CAP),
738            },
739            _ => Decision::Fatal,
740        }
741    }
742
743    /// Full jitter (AWS "Exponential Backoff and Jitter"): a uniform draw in
744    /// `[base/2, base]`.
745    ///
746    /// INVARIANT (T2-16): concurrent hotl processes do not retry in lockstep.
747    /// Enforced by `jitter_stays_in_range_and_actually_varies`.
748    ///
749    /// The entropy source is deliberately dependency-free: `RandomState` is
750    /// seeded per process and advances per call, which is exactly the
751    /// cross-process divergence a thundering herd needs. Not a security
752    /// context — do not reuse this for anything that is.
753    pub fn with_jitter(base: Duration) -> Duration {
754        use std::hash::{BuildHasher, Hasher};
755        let nanos = std::time::SystemTime::now()
756            .duration_since(std::time::UNIX_EPOCH)
757            .map(|d| d.subsec_nanos())
758            .unwrap_or(0);
759        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
760        h.write_u32(nanos);
761        h.write_u32(std::process::id());
762        let half = base / 2;
763        let span = base.saturating_sub(half).as_nanos() as u64;
764        if span == 0 {
765            return base;
766        }
767        half + Duration::from_nanos(h.finish() % (span + 1))
768    }
769
770    /// Seconds since the Unix epoch, for [`parse_retry_after`].
771    pub fn now_unix() -> u64 {
772        std::time::SystemTime::now()
773            .duration_since(std::time::UNIX_EPOCH)
774            .map(|d| d.as_secs())
775            .unwrap_or(0)
776    }
777
778    /// Parse a `Retry-After` header in **either** RFC 9110 form: delta-seconds
779    /// (`120`) or an IMF-fixdate (`Wed, 21 Oct 2015 07:28:00 GMT`). Returns
780    /// seconds to wait; a date already in the past yields `0`.
781    ///
782    /// Only the fixdate form is accepted for the date variant — the two
783    /// obsolete forms (RFC 850, asctime) are not emitted by any modern API and
784    /// a miss falls back to exponential backoff, which is safe.
785    pub fn parse_retry_after(value: &str, now_unix: u64) -> Option<u64> {
786        let v = value.trim();
787        if let Ok(secs) = v.parse::<u64>() {
788            return Some(secs);
789        }
790        // "Wed, 21 Oct 2015 07:28:00 GMT"
791        let rest = v.split_once(", ")?.1;
792        let mut parts = rest.split(' ');
793        let day: u32 = parts.next()?.parse().ok()?;
794        let month = match parts.next()? {
795            "Jan" => 1,
796            "Feb" => 2,
797            "Mar" => 3,
798            "Apr" => 4,
799            "May" => 5,
800            "Jun" => 6,
801            "Jul" => 7,
802            "Aug" => 8,
803            "Sep" => 9,
804            "Oct" => 10,
805            "Nov" => 11,
806            "Dec" => 12,
807            _ => return None,
808        };
809        let year: i64 = parts.next()?.parse().ok()?;
810        let mut hms = parts.next()?.split(':');
811        let h: u64 = hms.next()?.parse().ok()?;
812        let m: u64 = hms.next()?.parse().ok()?;
813        let s: u64 = hms.next()?.parse().ok()?;
814        if h > 23 || m > 59 || s > 60 || !(1..=31).contains(&day) {
815            return None;
816        }
817        let secs =
818            days_from_civil(year, month, day).checked_mul(86_400)? as u64 + h * 3600 + m * 60 + s;
819        Some(secs.saturating_sub(now_unix))
820    }
821
822    /// Days since 1970-01-01 for a proleptic-Gregorian civil date
823    /// (Howard Hinnant's `days_from_civil`). ~15 lines of `std` instead of a
824    /// date crate for one header.
825    fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
826        let y = if m <= 2 { y - 1 } else { y };
827        let era = if y >= 0 { y } else { y - 399 } / 400;
828        let yoe = y - era * 400; // [0, 399]
829        let mp = if m > 2 { m - 3 } else { m + 9 } as i64; // Mar = 0
830        let doy = (153 * mp + 2) / 5 + d as i64 - 1;
831        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
832        era * 146_097 + doe - 719_468
833    }
834
835    /// Availability-class errors are the only ones that justify falling back
836    /// to another model (never auth/billing/parse).
837    pub fn is_availability(err: &ProviderError) -> bool {
838        matches!(
839            err,
840            ProviderError::Http { status, .. } if *status == 429 || *status >= 500
841        ) || matches!(err, ProviderError::Transport(_))
842    }
843
844    /// Context-overflow detection (M2 compaction trigger). Both dialects
845    /// report overflow as a 400 whose message names the context/length limit;
846    /// this matches on *wire error* text (structured API data, not model
847    /// prose — the RELIABILITY.md rule concerns the latter). A miss is safe:
848    /// the pre-sample threshold catches what this doesn't.
849    pub fn is_context_overflow(err: &ProviderError) -> bool {
850        let ProviderError::Http {
851            status: 400,
852            message,
853            ..
854        } = err
855        else {
856            return false;
857        };
858        let m = message.to_lowercase();
859        [
860            "too long",
861            "context length",
862            "context window",
863            "tokens exceed",
864        ]
865        .iter()
866        .any(|needle| m.contains(needle))
867    }
868
869    #[cfg(test)]
870    mod tests {
871        use super::*;
872
873        #[test]
874        fn overflow_detection() {
875            let overflow = ProviderError::Http {
876                status: 400,
877                message:
878                    r#"{"error":{"message":"prompt is too long: 210000 tokens > 200000 maximum"}}"#
879                        .into(),
880                retry_after: None,
881            };
882            assert!(is_context_overflow(&overflow));
883            let oai = ProviderError::Http {
884                status: 400,
885                message: "This model's maximum context length is 128000 tokens".into(),
886                retry_after: None,
887            };
888            assert!(is_context_overflow(&oai));
889            let plain_400 = ProviderError::Http {
890                status: 400,
891                message: "bad schema".into(),
892                retry_after: None,
893            };
894            assert!(!is_context_overflow(&plain_400));
895        }
896
897        /// INVARIANT (T2-16): a server-supplied `Retry-After` never sleeps the
898        /// session for longer than `RETRY_AFTER_CAP`. `Retry-After: 3600` used
899        /// to sleep an hour, escapable only by killing the process.
900        #[test]
901        fn retry_after_is_capped() {
902            let hostile = ProviderError::Http {
903                status: 429,
904                message: String::new(),
905                retry_after: Some(3600),
906            };
907            assert_eq!(
908                classify(&hostile, 1),
909                Decision::Retry {
910                    delay: RETRY_AFTER_CAP
911                }
912            );
913        }
914
915        /// Backoff deepens for 529s: five attempts, base 1/2/4/8s.
916        #[test]
917        fn budget_is_deep_enough_for_an_overload() {
918            let overload = ProviderError::Http {
919                status: 529,
920                message: String::new(),
921                retry_after: None,
922            };
923            assert_eq!(MAX_ATTEMPTS, 5);
924            for (attempt, secs) in [(1u32, 1u64), (2, 2), (3, 4), (4, 8)] {
925                assert_eq!(
926                    classify(&overload, attempt),
927                    Decision::Retry {
928                        delay: std::time::Duration::from_secs(secs)
929                    },
930                    "attempt {attempt}"
931                );
932            }
933            assert_eq!(classify(&overload, MAX_ATTEMPTS), Decision::Fatal);
934        }
935
936        /// INVARIANT (T2-16): retries are jittered, so N processes backing off
937        /// the same upstream do not return in lockstep.
938        #[test]
939        fn jitter_stays_in_range_and_actually_varies() {
940            let base = std::time::Duration::from_secs(8);
941            let mut seen = std::collections::HashSet::new();
942            for _ in 0..500 {
943                let d = with_jitter(base);
944                assert!(d >= base / 2 && d <= base, "{d:?} outside [base/2, base]");
945                seen.insert(d.as_millis());
946            }
947            assert!(
948                seen.len() > 10,
949                "jitter is not varying: {} values",
950                seen.len()
951            );
952        }
953
954        /// Both `Retry-After` forms parse. The date form silently fell back to
955        /// exponential before R3.
956        #[test]
957        fn retry_after_parses_seconds_and_http_date() {
958            // 2015-10-21T07:28:00Z
959            const WHEN: u64 = 1_445_412_480;
960            assert_eq!(parse_retry_after("120", WHEN), Some(120));
961            assert_eq!(parse_retry_after("  120  ", WHEN), Some(120));
962            assert_eq!(
963                parse_retry_after("Wed, 21 Oct 2015 07:30:00 GMT", WHEN),
964                Some(120)
965            );
966            // A date already in the past means "retry now".
967            assert_eq!(
968                parse_retry_after("Wed, 21 Oct 2015 07:00:00 GMT", WHEN),
969                Some(0)
970            );
971            assert_eq!(parse_retry_after("not a date", WHEN), None);
972            // Leap-year and month-boundary sanity for the civil-days math.
973            assert_eq!(
974                parse_retry_after("Mon, 29 Feb 2016 00:00:01 GMT", 1_456_704_000),
975                Some(1)
976            );
977        }
978
979        #[test]
980        fn classify_rules() {
981            let overload = ProviderError::Http {
982                status: 529,
983                message: String::new(),
984                retry_after: Some(7),
985            };
986            assert_eq!(
987                classify(&overload, 1),
988                Decision::Retry {
989                    delay: std::time::Duration::from_secs(7)
990                }
991            );
992            assert_eq!(classify(&overload, MAX_ATTEMPTS), Decision::Fatal);
993            let auth = ProviderError::Auth("bad".into());
994            assert_eq!(classify(&auth, 1), Decision::Fatal);
995            assert!(!is_availability(&auth));
996            let transport = ProviderError::Transport("reset".into());
997            assert_eq!(
998                classify(&transport, 2),
999                Decision::Retry {
1000                    delay: std::time::Duration::from_secs(2)
1001                }
1002            );
1003            assert!(is_availability(&transport));
1004            let bad_req = ProviderError::Http {
1005                status: 400,
1006                message: String::new(),
1007                retry_after: None,
1008            };
1009            assert_eq!(classify(&bad_req, 1), Decision::Fatal);
1010        }
1011    }
1012}
1013
1014/// The named cross-provider canonicalization stage (`transform_messages`).
1015/// Canonical assistant blocks are
1016/// Anthropic-shaped; when a request targets a *different* provider than the
1017/// one that produced a block, provider-bound reasoning must not cross.
1018pub mod transform {
1019    use serde_json::Value;
1020
1021    /// Drop blocks that are provider-bound (signed/redacted thinking) when
1022    /// sending history to a foreign dialect. Text and tool_use always pass.
1023    pub fn strip_foreign_reasoning(blocks: &[Value]) -> Vec<Value> {
1024        blocks
1025            .iter()
1026            .filter(|b| {
1027                !matches!(
1028                    b.get("type").and_then(Value::as_str),
1029                    Some("thinking") | Some("redacted_thinking")
1030                )
1031            })
1032            .cloned()
1033            .collect()
1034    }
1035
1036    #[cfg(test)]
1037    mod tests {
1038        use super::*;
1039        use serde_json::json;
1040
1041        #[test]
1042        fn strips_thinking_keeps_rest() {
1043            let blocks = vec![
1044                json!({"type":"thinking","thinking":"x","signature":"s"}),
1045                json!({"type":"redacted_thinking","data":"d"}),
1046                json!({"type":"text","text":"hi"}),
1047                json!({"type":"tool_use","id":"1","name":"read","input":{}}),
1048            ];
1049            let out = strip_foreign_reasoning(&blocks);
1050            assert_eq!(out.len(), 2);
1051            assert_eq!(out[0]["type"], "text");
1052            assert_eq!(out[1]["type"], "tool_use");
1053        }
1054    }
1055}
1056
1057/// Arg healing at the erasure boundary (M3a): streamed tool
1058/// arguments sometimes arrive as *almost*-JSON. Repair is conservative —
1059/// only unambiguous damage is fixed; anything else stays a parse error that
1060/// feeds back to the model as a tool result.
1061pub mod repair {
1062    use serde_json::Value;
1063
1064    /// Strict parse, then repairs: strip trailing commas, then close
1065    /// truncated strings/objects/arrays (streams cut mid-argument).
1066    pub fn parse_or_repair(raw: &str) -> Option<Value> {
1067        if let Ok(v) = serde_json::from_str(raw) {
1068            return Some(v);
1069        }
1070        let without_commas = strip_trailing_commas(raw);
1071        if let Ok(v) = serde_json::from_str(&without_commas) {
1072            return Some(v);
1073        }
1074        serde_json::from_str(&close_truncation(&without_commas)).ok()
1075    }
1076
1077    /// `{"a": 1,}` / `[1, 2,]` → valid. Only commas directly before a closer.
1078    fn strip_trailing_commas(s: &str) -> String {
1079        let mut out = String::with_capacity(s.len());
1080        let mut in_string = false;
1081        let mut escaped = false;
1082        for c in s.chars() {
1083            if in_string {
1084                out.push(c);
1085                if escaped {
1086                    escaped = false;
1087                } else if c == '\\' {
1088                    escaped = true;
1089                } else if c == '"' {
1090                    in_string = false;
1091                }
1092                continue;
1093            }
1094            match c {
1095                '"' => {
1096                    in_string = true;
1097                    out.push(c);
1098                }
1099                '}' | ']' => {
1100                    while out.ends_with(char::is_whitespace) || out.ends_with(',') {
1101                        if out.ends_with(',') {
1102                            out.pop();
1103                            break;
1104                        }
1105                        out.pop();
1106                    }
1107                    out.push(c);
1108                }
1109                _ => out.push(c),
1110            }
1111        }
1112        out
1113    }
1114
1115    /// Close an unterminated string and any open brackets, in nesting order.
1116    fn close_truncation(s: &str) -> String {
1117        let mut stack = Vec::new();
1118        let mut in_string = false;
1119        let mut escaped = false;
1120        for c in s.chars() {
1121            if in_string {
1122                if escaped {
1123                    escaped = false;
1124                } else if c == '\\' {
1125                    escaped = true;
1126                } else if c == '"' {
1127                    in_string = false;
1128                }
1129                continue;
1130            }
1131            match c {
1132                '"' => in_string = true,
1133                '{' => stack.push('}'),
1134                '[' => stack.push(']'),
1135                '}' | ']' => {
1136                    stack.pop();
1137                }
1138                _ => {}
1139            }
1140        }
1141        let mut out = s.to_string();
1142        if in_string {
1143            out.push('"');
1144        }
1145        while let Some(closer) = stack.pop() {
1146            out.push(closer);
1147        }
1148        out
1149    }
1150
1151    #[cfg(test)]
1152    mod tests {
1153        use super::*;
1154
1155        #[test]
1156        fn repairs_common_damage_and_rejects_garbage() {
1157            assert_eq!(
1158                parse_or_repair(r#"{"path": "a.rs"}"#).unwrap()["path"],
1159                "a.rs"
1160            );
1161            assert_eq!(
1162                parse_or_repair(r#"{"path": "a.rs",}"#).unwrap()["path"],
1163                "a.rs"
1164            );
1165            assert_eq!(
1166                parse_or_repair(r#"{"items": [1, 2,]}"#).unwrap()["items"][1],
1167                2
1168            );
1169            // Truncated mid-string (stream cut): closed and parsed.
1170            let v = parse_or_repair(r#"{"command": "cargo tes"#).unwrap();
1171            assert_eq!(v["command"], "cargo tes");
1172            // A comma inside a string is untouched.
1173            assert_eq!(parse_or_repair(r#"{"t": "a,}"}"#).unwrap()["t"], "a,}");
1174            // Escaped quotes don't confuse the scanner.
1175            let v = parse_or_repair(r#"{"t": "say \"hi\"",}"#).unwrap();
1176            assert_eq!(v["t"], "say \"hi\"");
1177            assert!(parse_or_repair("not json at all").is_none());
1178        }
1179    }
1180}
1181
1182pub mod api_error;
1183pub mod catalog;
1184pub mod key;
1185
1186/// Highest block index any assembler will materialize. Wire indices are
1187/// attacker-controlled `u64`s; without a bound, `blocks.resize(index + 1, …)`
1188/// is a one-field OOM (T2-9). Far above any real response.
1189pub const MAX_BLOCK_INDEX: usize = 512;
1190
1191/// Wire-format folding, implemented per provider: turn one SSE `data:`
1192/// payload into events, and produce the terminal `Completed` at end-of-stream.
1193pub trait SseAssembler {
1194    fn handle(&mut self, data: &str) -> Result<Vec<StreamEvent>, ProviderError>;
1195    fn finish(self) -> Result<StreamEvent, ProviderError>;
1196}
1197
1198/// Drive an SSE byte stream through the line parser and an assembler.
1199/// Shared by every HTTP provider; no HTTP client dependency (the only
1200/// non-`std`/`futures` dep is `tokio::time`, which is wasm-supported).
1201///
1202/// INVARIANT (T1-4): a gap longer than `idle` between chunks ends the stream
1203/// with `ProviderError::Transport`. Enforced by
1204/// `drive_sse_tests::a_silent_stream_times_out_instead_of_hanging`.
1205pub fn drive_sse<B, E, A>(
1206    bytes: B,
1207    mut assembler: A,
1208    idle: std::time::Duration,
1209) -> impl futures_util::Stream<Item = Result<StreamEvent, ProviderError>>
1210where
1211    B: futures_util::Stream<Item = Result<bytes::Bytes, E>>,
1212    E: std::fmt::Display,
1213    A: SseAssembler,
1214{
1215    async_stream::stream! {
1216        let mut parser = SseParser::default();
1217        futures_util::pin_mut!(bytes);
1218        use futures_util::StreamExt;
1219        loop {
1220            let next = match tokio::time::timeout(idle, bytes.next()).await {
1221                Ok(next) => next,
1222                Err(_) => {
1223                    yield Err(ProviderError::Transport(format!(
1224                        "stream stalled: no data for {}s. The connection is likely dead \
1225                         (a proxy dropped it without closing); retry the request.",
1226                        idle.as_secs()
1227                    )));
1228                    return;
1229                }
1230            };
1231            let Some(chunk) = next else { break };
1232            let chunk = match chunk {
1233                Ok(c) => c,
1234                Err(e) => {
1235                    yield Err(ProviderError::Transport(format!("stream interrupted: {e}")));
1236                    return;
1237                }
1238            };
1239            let payloads = match parser.feed(&chunk) {
1240                Ok(payloads) => payloads,
1241                Err(e) => { yield Err(e); return; }
1242            };
1243            for data in payloads {
1244                match assembler.handle(&data) {
1245                    Ok(events) => for ev in events { yield Ok(ev); },
1246                    Err(e) => { yield Err(e); return; }
1247                }
1248            }
1249        }
1250        match parser.finish() {
1251            Ok(payloads) => {
1252                for data in payloads {
1253                    match assembler.handle(&data) {
1254                        Ok(events) => for ev in events { yield Ok(ev); },
1255                        Err(e) => { yield Err(e); return; }
1256                    }
1257                }
1258            }
1259            Err(e) => { yield Err(e); return; }
1260        }
1261        yield assembler.finish();
1262    }
1263}
1264
1265#[cfg(test)]
1266mod drive_sse_tests {
1267    use super::*;
1268    use futures_util::StreamExt;
1269    use std::time::Duration;
1270
1271    struct NeverEnds;
1272    impl SseAssembler for NeverEnds {
1273        fn handle(&mut self, _: &str) -> Result<Vec<StreamEvent>, ProviderError> {
1274            Ok(vec![])
1275        }
1276        fn finish(self) -> Result<StreamEvent, ProviderError> {
1277            Err(ProviderError::Parse("unreachable".into()))
1278        }
1279    }
1280
1281    /// INVARIANT (T1-4): a stream that goes silent is abandoned, never awaited
1282    /// forever. The real-world shape is a load balancer dropping the
1283    /// connection without a FIN — the bytes stream neither yields nor ends.
1284    #[tokio::test(start_paused = true)]
1285    async fn a_silent_stream_times_out_instead_of_hanging() {
1286        let stalled = futures_util::stream::pending::<Result<bytes::Bytes, std::io::Error>>();
1287        let s = drive_sse(stalled, NeverEnds, Duration::from_secs(5));
1288        futures_util::pin_mut!(s);
1289        let first = s.next().await.expect("an event, not a hang");
1290        match first {
1291            Err(ProviderError::Transport(m)) => {
1292                assert!(m.contains("stalled"), "{m}");
1293                assert!(m.contains("retry"), "errors are prompts: {m}");
1294            }
1295            other => panic!("expected a transport timeout, got {other:?}"),
1296        }
1297        assert!(
1298            s.next().await.is_none(),
1299            "the stream must end after the timeout"
1300        );
1301    }
1302
1303    /// A stream that keeps delivering inside the idle window is never cut,
1304    /// however long it runs in total. This is why the bound is per-chunk and
1305    /// not a request-level `.timeout()`.
1306    #[tokio::test(start_paused = true)]
1307    async fn a_slow_but_live_stream_is_not_cut() {
1308        let chunks = futures_util::stream::unfold(0u32, |n| async move {
1309            if n == 6 {
1310                return None;
1311            }
1312            tokio::time::sleep(Duration::from_secs(4)).await;
1313            Some((
1314                Ok::<_, std::io::Error>(bytes::Bytes::from_static(b":keepalive\n\n")),
1315                n + 1,
1316            ))
1317        });
1318        let s = drive_sse(chunks, NeverEnds, Duration::from_secs(5));
1319        futures_util::pin_mut!(s);
1320        let evs: Vec<_> = s.collect().await;
1321        // 24s of wall time, 5s idle bound, no timeout: only the assembler's
1322        // own end-of-stream error comes back.
1323        assert_eq!(evs.len(), 1);
1324        assert!(matches!(evs[0], Err(ProviderError::Parse(_))), "{evs:?}");
1325    }
1326}