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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ToolDef {
22    pub name: String,
23    pub description: String,
24    pub input_schema: Value,
25}
26
27#[derive(Debug, Clone)]
28pub struct SamplingRequest {
29    pub model: String,
30    pub max_tokens: u32,
31    /// Byte-stable owner system prompt (L6 discipline).
32    pub system: Arc<str>,
33    pub items: Arc<Vec<Item>>,
34    pub tools: Arc<[ToolDef]>,
35    /// Adaptive thinking on models that support it.
36    pub thinking: bool,
37    /// M0 static cache placement: system block + latest user block
38    /// (explicit-cache providers).
39    pub cache_static: bool,
40    /// MOIM (M2): ephemeral per-turn context, sent as a trailing user block
41    /// after the cache marker. Never persisted — it exists only on the wire.
42    pub turn_context: Option<String>,
43}
44
45/// The unified, channel-tagged, block-structured event enum.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(tag = "event", rename_all = "snake_case")]
48pub enum StreamEvent {
49    Started,
50    BlockStart {
51        index: usize,
52        kind: String,
53    },
54    TextDelta {
55        index: usize,
56        text: String,
57    },
58    ThinkingDelta {
59        index: usize,
60        text: String,
61    },
62    ToolInputDelta {
63        index: usize,
64        json: String,
65    },
66    BlockEnd {
67        index: usize,
68    },
69    Retrying {
70        attempt: u32,
71        reason: String,
72    },
73    /// Terminal event: the provider-assembled verbatim assistant blocks
74    /// (echo these back on the next request — replay-safe by construction).
75    Completed {
76        stop: StopReason,
77        usage: TokenUsage,
78        blocks: Vec<Value>,
79    },
80}
81
82#[derive(Debug, thiserror::Error)]
83pub enum ProviderError {
84    #[error("authentication failed: {0}")]
85    Auth(String),
86    #[error("HTTP {status}: {message}")]
87    Http {
88        status: u16,
89        message: String,
90        retry_after: Option<u64>,
91    },
92    #[error("transport error: {0}")]
93    Transport(String),
94    #[error("stream parse error: {0}")]
95    Parse(String),
96}
97
98pub trait Provider: Send + Sync {
99    fn stream(
100        &self,
101        req: SamplingRequest,
102    ) -> BoxStream<'static, Result<StreamEvent, ProviderError>>;
103}
104
105/// The honest "second impl" (D9): a scripted provider driving the real engine
106/// in tests. Each `stream()` call pops the next script.
107pub struct ScriptedProvider {
108    scripts: Mutex<VecDeque<Vec<Result<StreamEvent, ProviderError>>>>,
109    /// Every request the engine made, for test assertions on what the model saw.
110    requests: Mutex<Vec<SamplingRequest>>,
111}
112
113impl ScriptedProvider {
114    pub fn new(scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>) -> Self {
115        Self {
116            scripts: Mutex::new(scripts.into()),
117            requests: Mutex::new(Vec::new()),
118        }
119    }
120
121    /// Every captured request. Cheap since a request's history/tools are
122    /// shared (`Arc`) — the clone copies pointers, not items.
123    pub fn requests(&self) -> Vec<SamplingRequest> {
124        self.requests.lock().expect("requests mutex").clone()
125    }
126
127    /// The most recent request, if any.
128    pub fn last_request(&self) -> Option<SamplingRequest> {
129        self.requests
130            .lock()
131            .expect("requests mutex")
132            .last()
133            .cloned()
134    }
135
136    pub fn request_count(&self) -> usize {
137        self.requests.lock().expect("requests mutex").len()
138    }
139
140    /// Append a script after construction (tests that need the harness's
141    /// paths before the scripts can be written).
142    pub fn push_script(&self, script: Vec<Result<StreamEvent, ProviderError>>) {
143        self.scripts
144            .lock()
145            .expect("scripted provider mutex")
146            .push_back(script);
147    }
148
149    /// Convenience: a one-sample script that answers with plain text.
150    pub fn text_reply(text: &str) -> Vec<Result<StreamEvent, ProviderError>> {
151        vec![
152            Ok(StreamEvent::Started),
153            Ok(StreamEvent::BlockStart {
154                index: 0,
155                kind: "text".into(),
156            }),
157            Ok(StreamEvent::TextDelta {
158                index: 0,
159                text: text.into(),
160            }),
161            Ok(StreamEvent::BlockEnd { index: 0 }),
162            Ok(StreamEvent::Completed {
163                stop: StopReason::EndTurn,
164                usage: TokenUsage {
165                    input_tokens: 10,
166                    output_tokens: 5,
167                    ..Default::default()
168                },
169                blocks: vec![serde_json::json!({"type": "text", "text": text})],
170            }),
171        ]
172    }
173
174    /// Convenience: a sample that calls one tool.
175    pub fn tool_call(
176        id: &str,
177        name: &str,
178        input: Value,
179    ) -> Vec<Result<StreamEvent, ProviderError>> {
180        let block = serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input});
181        vec![
182            Ok(StreamEvent::Started),
183            Ok(StreamEvent::BlockStart {
184                index: 0,
185                kind: "tool_use".into(),
186            }),
187            Ok(StreamEvent::BlockEnd { index: 0 }),
188            Ok(StreamEvent::Completed {
189                stop: StopReason::ToolUse,
190                usage: TokenUsage {
191                    input_tokens: 10,
192                    output_tokens: 8,
193                    ..Default::default()
194                },
195                blocks: vec![block],
196            }),
197        ]
198    }
199}
200
201impl Provider for ScriptedProvider {
202    fn stream(
203        &self,
204        req: SamplingRequest,
205    ) -> BoxStream<'static, Result<StreamEvent, ProviderError>> {
206        self.requests.lock().expect("requests mutex").push(req);
207        let script = self
208            .scripts
209            .lock()
210            .expect("scripted provider mutex")
211            .pop_front()
212            .unwrap_or_else(|| {
213                vec![Err(ProviderError::Transport(
214                    "scripted provider exhausted".into(),
215                ))]
216            });
217        Box::pin(futures_util::stream::iter(script))
218    }
219}
220
221/// SSE line parsing shared by HTTP providers: turns raw byte chunks into
222/// complete `data:` payload strings. Chunks can split mid-line (and mid-UTF-8
223/// code point), so bytes are buffered, not lossily decoded per chunk.
224#[derive(Default)]
225pub struct SseParser {
226    buf: Vec<u8>,
227}
228
229/// A stream that never sends a newline must not buffer without bound.
230const SSE_MAX_BUFFER: usize = 1024 * 1024;
231
232impl SseParser {
233    /// Feed a chunk; returns complete `data:` payloads (`[DONE]` filtered).
234    /// Errors when a single line exceeds [`SSE_MAX_BUFFER`].
235    pub fn feed(&mut self, chunk: &[u8]) -> Result<Vec<String>, ProviderError> {
236        self.buf.extend_from_slice(chunk);
237        let mut out = Vec::new();
238        let mut start = 0;
239        while let Some(pos) = self.buf[start..].iter().position(|&b| b == b'\n') {
240            let line = String::from_utf8_lossy(&self.buf[start..start + pos]);
241            let line = line.trim_end_matches('\r');
242            if let Some(data) = line.strip_prefix("data:") {
243                let data = data.trim_start();
244                if !data.is_empty() && data != "[DONE]" {
245                    out.push(data.to_string());
246                }
247            }
248            start += pos + 1;
249        }
250        self.buf.drain(..start);
251        if self.buf.len() > SSE_MAX_BUFFER {
252            return Err(ProviderError::Parse(format!(
253                "SSE line exceeded {SSE_MAX_BUFFER} bytes without a newline"
254            )));
255        }
256        Ok(out)
257    }
258}
259
260/// Pure-data retry classification (RELIABILITY.md — never regex
261/// on prose). Both HTTP providers consult this; budgets reset per sample.
262pub mod retry {
263    use super::ProviderError;
264
265    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
266    pub enum Decision {
267        /// Wait this many seconds, then retry the request.
268        Retry { after_secs: u64 },
269        /// Not recoverable by retrying (auth, parse, client errors).
270        Fatal,
271    }
272
273    pub const MAX_ATTEMPTS: u32 = 3;
274
275    /// `attempt` is 1-based (the attempt that just failed).
276    pub fn classify(err: &ProviderError, attempt: u32) -> Decision {
277        if attempt >= MAX_ATTEMPTS {
278            return Decision::Fatal;
279        }
280        match err {
281            ProviderError::Http {
282                status,
283                retry_after,
284                ..
285            } if *status == 429 || *status >= 500 => Decision::Retry {
286                after_secs: retry_after.unwrap_or(1u64 << (attempt - 1)),
287            },
288            ProviderError::Transport(_) => Decision::Retry {
289                after_secs: 1u64 << (attempt - 1),
290            },
291            _ => Decision::Fatal,
292        }
293    }
294
295    /// Availability-class errors are the only ones that justify falling back
296    /// to another model (never auth/billing/parse).
297    pub fn is_availability(err: &ProviderError) -> bool {
298        matches!(
299            err,
300            ProviderError::Http { status, .. } if *status == 429 || *status >= 500
301        ) || matches!(err, ProviderError::Transport(_))
302    }
303
304    /// Context-overflow detection (M2 compaction trigger). Both dialects
305    /// report overflow as a 400 whose message names the context/length limit;
306    /// this matches on *wire error* text (structured API data, not model
307    /// prose — the RELIABILITY.md rule concerns the latter). A miss is safe:
308    /// the pre-sample threshold catches what this doesn't.
309    pub fn is_context_overflow(err: &ProviderError) -> bool {
310        let ProviderError::Http {
311            status: 400,
312            message,
313            ..
314        } = err
315        else {
316            return false;
317        };
318        let m = message.to_lowercase();
319        [
320            "too long",
321            "context length",
322            "context window",
323            "tokens exceed",
324        ]
325        .iter()
326        .any(|needle| m.contains(needle))
327    }
328
329    #[cfg(test)]
330    mod tests {
331        use super::*;
332
333        #[test]
334        fn overflow_detection() {
335            let overflow = ProviderError::Http {
336                status: 400,
337                message:
338                    r#"{"error":{"message":"prompt is too long: 210000 tokens > 200000 maximum"}}"#
339                        .into(),
340                retry_after: None,
341            };
342            assert!(is_context_overflow(&overflow));
343            let oai = ProviderError::Http {
344                status: 400,
345                message: "This model's maximum context length is 128000 tokens".into(),
346                retry_after: None,
347            };
348            assert!(is_context_overflow(&oai));
349            let plain_400 = ProviderError::Http {
350                status: 400,
351                message: "bad schema".into(),
352                retry_after: None,
353            };
354            assert!(!is_context_overflow(&plain_400));
355        }
356
357        #[test]
358        fn classify_rules() {
359            let overload = ProviderError::Http {
360                status: 529,
361                message: String::new(),
362                retry_after: Some(7),
363            };
364            assert_eq!(classify(&overload, 1), Decision::Retry { after_secs: 7 });
365            assert_eq!(classify(&overload, MAX_ATTEMPTS), Decision::Fatal);
366            let auth = ProviderError::Auth("bad".into());
367            assert_eq!(classify(&auth, 1), Decision::Fatal);
368            assert!(!is_availability(&auth));
369            let transport = ProviderError::Transport("reset".into());
370            assert_eq!(classify(&transport, 2), Decision::Retry { after_secs: 2 });
371            assert!(is_availability(&transport));
372            let bad_req = ProviderError::Http {
373                status: 400,
374                message: String::new(),
375                retry_after: None,
376            };
377            assert_eq!(classify(&bad_req, 1), Decision::Fatal);
378        }
379    }
380}
381
382/// The named cross-provider canonicalization stage (`transform_messages`).
383/// Canonical assistant blocks are
384/// Anthropic-shaped; when a request targets a *different* provider than the
385/// one that produced a block, provider-bound reasoning must not cross.
386pub mod transform {
387    use serde_json::Value;
388
389    /// Drop blocks that are provider-bound (signed/redacted thinking) when
390    /// sending history to a foreign dialect. Text and tool_use always pass.
391    pub fn strip_foreign_reasoning(blocks: &[Value]) -> Vec<Value> {
392        blocks
393            .iter()
394            .filter(|b| {
395                !matches!(
396                    b.get("type").and_then(Value::as_str),
397                    Some("thinking") | Some("redacted_thinking")
398                )
399            })
400            .cloned()
401            .collect()
402    }
403
404    #[cfg(test)]
405    mod tests {
406        use super::*;
407        use serde_json::json;
408
409        #[test]
410        fn strips_thinking_keeps_rest() {
411            let blocks = vec![
412                json!({"type":"thinking","thinking":"x","signature":"s"}),
413                json!({"type":"redacted_thinking","data":"d"}),
414                json!({"type":"text","text":"hi"}),
415                json!({"type":"tool_use","id":"1","name":"read","input":{}}),
416            ];
417            let out = strip_foreign_reasoning(&blocks);
418            assert_eq!(out.len(), 2);
419            assert_eq!(out[0]["type"], "text");
420            assert_eq!(out[1]["type"], "tool_use");
421        }
422    }
423}
424
425/// Arg healing at the erasure boundary (M3a): streamed tool
426/// arguments sometimes arrive as *almost*-JSON. Repair is conservative —
427/// only unambiguous damage is fixed; anything else stays a parse error that
428/// feeds back to the model as a tool result.
429pub mod repair {
430    use serde_json::Value;
431
432    /// Strict parse, then repairs: strip trailing commas, then close
433    /// truncated strings/objects/arrays (streams cut mid-argument).
434    pub fn parse_or_repair(raw: &str) -> Option<Value> {
435        if let Ok(v) = serde_json::from_str(raw) {
436            return Some(v);
437        }
438        let without_commas = strip_trailing_commas(raw);
439        if let Ok(v) = serde_json::from_str(&without_commas) {
440            return Some(v);
441        }
442        serde_json::from_str(&close_truncation(&without_commas)).ok()
443    }
444
445    /// `{"a": 1,}` / `[1, 2,]` → valid. Only commas directly before a closer.
446    fn strip_trailing_commas(s: &str) -> String {
447        let mut out = String::with_capacity(s.len());
448        let mut in_string = false;
449        let mut escaped = false;
450        for c in s.chars() {
451            if in_string {
452                out.push(c);
453                if escaped {
454                    escaped = false;
455                } else if c == '\\' {
456                    escaped = true;
457                } else if c == '"' {
458                    in_string = false;
459                }
460                continue;
461            }
462            match c {
463                '"' => {
464                    in_string = true;
465                    out.push(c);
466                }
467                '}' | ']' => {
468                    while out.ends_with(char::is_whitespace) || out.ends_with(',') {
469                        if out.ends_with(',') {
470                            out.pop();
471                            break;
472                        }
473                        out.pop();
474                    }
475                    out.push(c);
476                }
477                _ => out.push(c),
478            }
479        }
480        out
481    }
482
483    /// Close an unterminated string and any open brackets, in nesting order.
484    fn close_truncation(s: &str) -> String {
485        let mut stack = Vec::new();
486        let mut in_string = false;
487        let mut escaped = false;
488        for c in s.chars() {
489            if in_string {
490                if escaped {
491                    escaped = false;
492                } else if c == '\\' {
493                    escaped = true;
494                } else if c == '"' {
495                    in_string = false;
496                }
497                continue;
498            }
499            match c {
500                '"' => in_string = true,
501                '{' => stack.push('}'),
502                '[' => stack.push(']'),
503                '}' | ']' => {
504                    stack.pop();
505                }
506                _ => {}
507            }
508        }
509        let mut out = s.to_string();
510        if in_string {
511            out.push('"');
512        }
513        while let Some(closer) = stack.pop() {
514            out.push(closer);
515        }
516        out
517    }
518
519    #[cfg(test)]
520    mod tests {
521        use super::*;
522
523        #[test]
524        fn repairs_common_damage_and_rejects_garbage() {
525            assert_eq!(
526                parse_or_repair(r#"{"path": "a.rs"}"#).unwrap()["path"],
527                "a.rs"
528            );
529            assert_eq!(
530                parse_or_repair(r#"{"path": "a.rs",}"#).unwrap()["path"],
531                "a.rs"
532            );
533            assert_eq!(
534                parse_or_repair(r#"{"items": [1, 2,]}"#).unwrap()["items"][1],
535                2
536            );
537            // Truncated mid-string (stream cut): closed and parsed.
538            let v = parse_or_repair(r#"{"command": "cargo tes"#).unwrap();
539            assert_eq!(v["command"], "cargo tes");
540            // A comma inside a string is untouched.
541            assert_eq!(parse_or_repair(r#"{"t": "a,}"}"#).unwrap()["t"], "a,}");
542            // Escaped quotes don't confuse the scanner.
543            let v = parse_or_repair(r#"{"t": "say \"hi\"",}"#).unwrap();
544            assert_eq!(v["t"], "say \"hi\"");
545            assert!(parse_or_repair("not json at all").is_none());
546        }
547    }
548}
549
550pub mod key;
551
552/// Wire-format folding, implemented per provider: turn one SSE `data:`
553/// payload into events, and produce the terminal `Completed` at end-of-stream.
554pub trait SseAssembler {
555    fn handle(&mut self, data: &str) -> Result<Vec<StreamEvent>, ProviderError>;
556    fn finish(self) -> Result<StreamEvent, ProviderError>;
557}
558
559/// Drive an SSE byte stream through the line parser and an assembler.
560/// Shared by every HTTP provider; wasm-clean (no HTTP client dependency).
561pub fn drive_sse<B, E, A>(
562    bytes: B,
563    mut assembler: A,
564) -> impl futures_util::Stream<Item = Result<StreamEvent, ProviderError>>
565where
566    B: futures_util::Stream<Item = Result<bytes::Bytes, E>>,
567    E: std::fmt::Display,
568    A: SseAssembler,
569{
570    async_stream::stream! {
571        let mut parser = SseParser::default();
572        futures_util::pin_mut!(bytes);
573        use futures_util::StreamExt;
574        while let Some(chunk) = bytes.next().await {
575            let chunk = match chunk {
576                Ok(c) => c,
577                Err(e) => {
578                    yield Err(ProviderError::Transport(format!("stream interrupted: {e}")));
579                    return;
580                }
581            };
582            let payloads = match parser.feed(&chunk) {
583                Ok(payloads) => payloads,
584                Err(e) => { yield Err(e); return; }
585            };
586            for data in payloads {
587                match assembler.handle(&data) {
588                    Ok(events) => for ev in events { yield Ok(ev); },
589                    Err(e) => { yield Err(e); return; }
590                }
591            }
592        }
593        yield assembler.finish();
594    }
595}