Skip to main content

cratefield_core/ports/
text_model.rs

1//! The `TextModel` port (issue #429): a text completion asked for by
2//! **tier**, never by vendor. A module says [`ModelTier::Fast`] for its
3//! drafting and [`ModelTier::Strong`] for its judging; which provider
4//! answers each tier is the venture's wiring, decided once in `src/lib.rs`
5//! and invisible to the module (ADR 0002). [`RoutingTextModel`] is the seam,
6//! the way [`RoutingPush`](crate::RoutingPush) is for transports.
7//!
8//! **Tools arrived in issue #665.** A
9//! [`Prompt`] may carry [`ToolSpec`]s and a [`ToolChoice`]; a
10//! [`Completion`] may carry the [`ToolCall`]s the model asked for; and a
11//! [`Turn`] can quote an assistant's requested calls and the
12//! [`ToolResult`]s a user turn carries back. An adapter that can carry
13//! tools says so through [`TextModel::supports`]; one that cannot is never
14//! asked — [`RoutingTextModel`] and [`run_tool_loop`](crate::run_tool_loop)
15//! refuse a tools-bearing prompt up front with
16//! [`TextModelError::Unsupported`]. Embeddings live on the separate
17//! [`Embedder`](crate::Embedder) port (issue #561), never here.
18//!
19//! **Structured output builds on the schema a prompt already carries.**
20//! [`TextModelExt`](crate::TextModelExt) wraps `complete` with
21//! `complete_json`/`complete_as`: it checks the schema on the tool loop's
22//! subset, asks for it through [`Prompt::json_schema`], validates the
23//! answer, and allows one repair retry — so a caller holds a value that
24//! conforms or a [`TextModelError::SchemaViolation`], never a partial one.
25//!
26//! **Streaming is part of the port (issue #859).** [`TextModel::stream`]
27//! yields [`TextDelta`]s — text and reasoning as they arrive, each tool
28//! call as it starts, grows arguments and finishes, running usage, and
29//! exactly one `Finish` — and defaults to a single `complete` round trip
30//! decomposed by [`completion_deltas`], so every adapter that existed
31//! before the method did streams unchanged; one whose provider streams
32//! natively overrides it. [`CompletionBuilder`] runs the other way,
33//! reassembling deltas into a [`Completion`]. For a route handler,
34//! [`stream_owned`] turns an `Arc` and an owned prompt into a `'static`
35//! delta stream — the input [`ResponseStream`](crate::ResponseStream)
36//! bridges to the wire without the 1 MiB `response_to_worker` buffer,
37//! which is why the port can promise deltas now: issue #585 added
38//! [`Module::streaming_routes`](crate::Module::streaming_routes) and the
39//! runtime that could not keep the promise bridges it. Dropping the
40//! stream cancels the completion mid-flight, the same contract
41//! [`HttpClient::send_streaming`](crate::HttpClient::send_streaming)
42//! carries; the outbound bounds a streaming adapter works under are
43//! [`StreamPolicy`](crate::StreamPolicy)'s.
44//!
45//! **Image input arrived in issue #628.** A [`Turn`] may carry [`Part`]s —
46//! text and images in order — built with [`Turn::user_parts`]; a text-only
47//! turn is unchanged. An adapter that can carry images says so through
48//! [`Capability::Images`], and the router, the tool loop and every adapter
49//! call [`Prompt::check_images`] **before** any network call, so an
50//! over-limit prompt is refused locally rather than paid for. Adapters
51//! encode with [`encode_image`], needing no base64 dependency of their own.
52//!
53//! There is no outcome enum on this port, unlike [`Mailer`](crate::Mailer)
54//! and [`Push`](crate::Push), and that is deliberate: a completion has no
55//! "delivered but not configured" middle state — either text came back or
56//! nothing did. The unwired answer is therefore
57//! [`TextModelError::NotConfigured`], an error variant the caller can match,
58//! so a module that cannot degrade without its model fails loudly instead of
59//! silently producing nothing.
60
61use std::sync::Arc;
62use std::time::Duration;
63
64use async_trait::async_trait;
65use base64::Engine as _;
66use futures_util::SinkExt as _;
67use futures_util::StreamExt;
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70
71use super::http::MAX_RESPONSE_BYTES;
72use crate::stream::{BoxStream, pump};
73
74/// The token ceiling a [`Prompt`] starts with: enough for a drafted reply,
75/// small enough that a forgotten `.max_tokens(..)` cannot turn into a run
76/// away bill. Explicit in the type so "how long can the answer get" is a
77/// field a caller can read, not an adapter's private default.
78pub const DEFAULT_MAX_TOKENS: u32 = 1024;
79
80/// The most images one [`Prompt`] may carry (issue #628): four, inside the
81/// vendors' own per-request range. [`Prompt::check_images`] refuses more.
82pub const MAX_PROMPT_IMAGES: usize = 4;
83
84/// The most base64 bytes one image may encode to (issue #628): 3.75 MiB,
85/// the vendors' per-image ceiling. [`encoded_image_len`] gives a raw
86/// image's encoded length; [`Prompt::check_images`] refuses any over it.
87pub const MAX_IMAGE_ENCODED_BYTES: usize = 3_932_160;
88
89/// The most base64 bytes **all** of a prompt's images may encode to
90/// together (issue #628): 4 MiB, the same [`MAX_RESPONSE_BYTES`] ceiling
91/// the [`HttpClient`](crate::HttpClient) port puts on a response body.
92/// [`Prompt::check_images`] refuses a total over it.
93pub const MAX_PROMPT_IMAGE_ENCODED_BYTES: usize = MAX_RESPONSE_BYTES;
94
95/// Which class of model a completion asks for — a **tier**, never a vendor
96/// or a model name. A venture maps each tier onto a provider in its own
97/// wiring, and can move drafting from one vendor to another without
98/// touching a module.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum ModelTier {
102    /// The cheap, quick class: drafting, summarising, classifying.
103    Fast,
104    /// The best-available class: judging, long synthesis, the one call
105    /// where quality is the point.
106    Strong,
107}
108
109impl ModelTier {
110    /// The name used in errors and logs.
111    pub fn name(&self) -> &'static str {
112        match self {
113            ModelTier::Fast => "fast",
114            ModelTier::Strong => "strong",
115        }
116    }
117}
118
119impl std::fmt::Display for ModelTier {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.write_str(self.name())
122    }
123}
124
125/// Who is speaking in a [`Turn`]. A prompt is a conversation, and a
126/// provider needs to know which side each part came from.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum Role {
130    /// The human (or module) side of the conversation.
131    User,
132    /// The model's side, as a previous completion was recorded.
133    Assistant,
134}
135
136impl Role {
137    /// The name used in errors and logs.
138    pub fn name(&self) -> &'static str {
139        match self {
140            Role::User => "user",
141            Role::Assistant => "assistant",
142        }
143    }
144}
145
146impl std::fmt::Display for Role {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.write_str(self.name())
149    }
150}
151
152/// Something a [`TextModel`] adapter may or may not be able to do beyond a
153/// plain completion. A port whose shape grows over time needs a way for the
154/// router to ask "can the model behind this tier actually do this" before a
155/// caller wastes a request on one that cannot.
156///
157/// `#[non_exhaustive]`: today the capabilities are [`Self::Tools`] and
158/// [`Self::Images`], and the next one — a provider-native structured
159/// output — should not be a breaking change for every `match` a caller
160/// writes.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162#[non_exhaustive]
163pub enum Capability {
164    /// The adapter carries tools: a prompt's [`Prompt::tools`] reach the
165    /// provider, and the answer may carry [`Completion::tool_calls`]. An
166    /// adapter that does not implement this is never asked to.
167    Tools,
168    /// The adapter carries images: a prompt's [`Part::Image`]s reach the
169    /// provider. An adapter without vision is refused with
170    /// [`TextModelError::Unsupported`], never sent an image to drop.
171    Images,
172}
173
174impl Capability {
175    /// The name used in errors and logs.
176    pub fn name(&self) -> &'static str {
177        match self {
178            Capability::Tools => "tools",
179            Capability::Images => "images",
180        }
181    }
182}
183
184impl std::fmt::Display for Capability {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.name())
187    }
188}
189
190/// A tool the model is offered: a name, the instruction a model reads when
191/// deciding whether to call it, and the JSON Schema of the arguments object
192/// the call must carry (issue #665).
193///
194/// `#[non_exhaustive]`: build one with [`ToolSpec::new`] rather than a
195/// struct literal, the same rule [`Prompt`] and [`Completion`] follow —
196/// what a tool declares grows, and it should not break every caller.
197#[derive(Debug, Clone, PartialEq)]
198#[non_exhaustive]
199pub struct ToolSpec {
200    pub name: String,
201    pub description: String,
202    /// A JSON Schema (draft 2020-12) of the arguments object a call must
203    /// provide. [`run_tool_loop`](crate::run_tool_loop) validates it up
204    /// front and then validates every call's arguments against it, and
205    /// refuses a schema using a keyword it cannot honour rather than
206    /// silently skipping the check.
207    pub parameters: Value,
208}
209
210impl ToolSpec {
211    /// A tool named `name`, described by `description`, taking arguments
212    /// conforming to `parameters`.
213    #[must_use]
214    pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: Value) -> Self {
215        Self {
216            name: name.into(),
217            description: description.into(),
218            parameters,
219        }
220    }
221}
222
223/// How the model should choose among a prompt's [`Prompt::tools`]: freely,
224/// not at all, mandatorily, or pinned to one named tool (issue #665).
225///
226/// `#[non_exhaustive]`: the wire protocols have more ways to steer this
227/// ("any tool but this one", a per-tool probability), and adding one must
228/// not break every caller.
229#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub enum ToolChoice {
232    /// The model decides whether to call a tool or answer directly.
233    Auto,
234    /// The model must answer without calling a tool.
235    None,
236    /// The model must call some tool, whichever it picks.
237    Required,
238    /// The model must call the tool with this name.
239    Tool(String),
240}
241
242/// One tool call the model asked for: which call it is, which tool, and the
243/// arguments the model produced (issue #665). The arguments are exactly
244/// what the model sent — [`run_tool_loop`](crate::run_tool_loop) validates
245/// them against the tool's schema before any executor sees them.
246///
247/// Serialization arrived with streaming (issue #859):
248/// [`TextDelta::ToolCallFinished`](crate::TextDelta::ToolCallFinished)
249/// carries a whole call across the wire, so the call itself has to ride
250/// along.
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252pub struct ToolCall {
253    pub id: String,
254    pub name: String,
255    pub arguments: Value,
256}
257
258impl ToolCall {
259    /// A call identified by `id` asking for the tool named `name` with
260    /// `arguments`.
261    #[must_use]
262    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
263        Self {
264            id: id.into(),
265            name: name.into(),
266            arguments,
267        }
268    }
269}
270
271/// The result of running one [`ToolCall`], fed back to the model as
272/// context (issue #665). A tool-level failure is a [`ToolResult::error`],
273/// not a failed loop: the model is given the error text and may try again.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct ToolResult {
276    /// The [`ToolCall::id`] this answers.
277    pub tool_call_id: String,
278    pub content: String,
279    /// Whether `content` is a tool error the model should see rather than a
280    /// successful result.
281    pub is_error: bool,
282}
283
284impl ToolResult {
285    /// A successful result for the call `tool_call_id`.
286    #[must_use]
287    pub fn ok(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
288        Self {
289            tool_call_id: tool_call_id.into(),
290            content: content.into(),
291            is_error: false,
292        }
293    }
294
295    /// A failed result for the call `tool_call_id`: `content` is the error
296    /// the model is shown so it can recover.
297    #[must_use]
298    pub fn error(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
299        Self {
300            tool_call_id: tool_call_id.into(),
301            content: content.into(),
302            is_error: true,
303        }
304    }
305}
306
307/// The media type of an image a [`Part`] carries (issue #628): the closed
308/// set the providers accept, so a caller picks one rather than handing the
309/// provider a bare string to validate. `#[non_exhaustive]`, so a `match`
310/// carries a wildcard.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[non_exhaustive]
313pub enum ImageMediaType {
314    /// `image/png`.
315    Png,
316    /// `image/jpeg`.
317    Jpeg,
318    /// `image/gif`.
319    Gif,
320    /// `image/webp`.
321    Webp,
322}
323
324impl ImageMediaType {
325    /// The MIME type a provider expects on the wire.
326    #[must_use]
327    pub const fn as_str(self) -> &'static str {
328        match self {
329            ImageMediaType::Png => "image/png",
330            ImageMediaType::Jpeg => "image/jpeg",
331            ImageMediaType::Gif => "image/gif",
332            ImageMediaType::Webp => "image/webp",
333        }
334    }
335}
336
337/// One piece of a [`Turn`]: text, or an image with its media type and raw
338/// bytes (issue #628). A parts-bearing turn carries them **in order**;
339/// `#[non_exhaustive]`, so a part is built with [`Part::text`] and
340/// [`Part::image`] rather than a struct literal.
341#[derive(Debug, Clone, PartialEq, Eq)]
342#[non_exhaustive]
343pub enum Part {
344    /// A run of text.
345    Text(String),
346    /// An image: its media type and its raw (undecoded) bytes. Adapters
347    /// encode with [`encode_image`] when they put it on the wire.
348    Image {
349        media_type: ImageMediaType,
350        bytes: Vec<u8>,
351    },
352}
353
354impl Part {
355    /// A text part.
356    #[must_use]
357    pub fn text(text: impl Into<String>) -> Self {
358        Part::Text(text.into())
359    }
360
361    /// An image part of `media_type` carrying the raw `bytes`.
362    #[must_use]
363    pub fn image(media_type: ImageMediaType, bytes: impl Into<Vec<u8>>) -> Self {
364        Part::Image {
365            media_type,
366            bytes: bytes.into(),
367        }
368    }
369}
370
371/// The standard, padded base64 encoding of an image's raw bytes (issue
372/// #628) — the form every provider expects for inline image data. Here so
373/// an adapter needs no base64 dependency of its own.
374#[must_use]
375pub fn encode_image(bytes: &[u8]) -> String {
376    base64::engine::general_purpose::STANDARD.encode(bytes)
377}
378
379/// The length of [`encode_image`]'s output for `raw_len` raw bytes (issue
380/// #628): `4 * ceil(raw_len / 3)`, padding included. Pure arithmetic, so
381/// [`Prompt::check_images`] can size an image without encoding it — to
382/// refuse an over-limit image before the allocation, not after.
383#[must_use]
384pub const fn encoded_image_len(raw_len: usize) -> usize {
385    raw_len.div_ceil(3) * 4
386}
387
388/// One message of the conversation a [`Prompt`] carries.
389///
390/// `#[non_exhaustive]`: a turn grew its tool fields in issue #665 and its
391/// image [`Part`]s in issue #628 and will grow again, so it is built with
392/// [`Turn::user`], [`Turn::user_parts`], [`Turn::assistant`],
393/// [`Turn::assistant_tool_calls`] or [`Turn::tool_results`] rather than a
394/// struct literal.
395#[derive(Debug, Clone, PartialEq)]
396#[non_exhaustive]
397pub struct Turn {
398    pub role: Role,
399    pub content: String,
400    /// The turn's content as ordered [`Part`]s, where it carries any
401    /// (issue #628). When non-empty it **is** the whole content, in order,
402    /// and [`content`](Self::content) is empty; a text-only turn keeps it
403    /// empty so it serialises exactly as before images existed.
404    pub parts: Vec<Part>,
405    /// The calls an assistant turn asked for. Carried on a
406    /// [`Role::Assistant`] turn; empty on every other turn.
407    pub tool_calls: Vec<ToolCall>,
408    /// The results a user turn carries back for the previous assistant
409    /// turn's calls. Carried on a [`Role::User`] turn; empty otherwise.
410    pub tool_results: Vec<ToolResult>,
411}
412
413impl Turn {
414    /// A turn spoken by the [`Role::User`] side.
415    #[must_use]
416    pub fn user(content: impl Into<String>) -> Self {
417        Turn {
418            role: Role::User,
419            content: content.into(),
420            parts: Vec::new(),
421            tool_calls: Vec::new(),
422            tool_results: Vec::new(),
423        }
424    }
425
426    /// A user turn carrying `parts` — text and images in order — with an
427    /// empty [`content`](Self::content) (issue #628). The way a prompt
428    /// sends an image: providers expect text and image in one message, so
429    /// the parts travel together rather than across two turns.
430    #[must_use]
431    pub fn user_parts(parts: impl IntoIterator<Item = Part>) -> Self {
432        Turn {
433            role: Role::User,
434            content: String::new(),
435            parts: parts.into_iter().collect(),
436            tool_calls: Vec::new(),
437            tool_results: Vec::new(),
438        }
439    }
440
441    /// A turn spoken by the [`Role::Assistant`] side — a previous
442    /// completion, quoted back as context.
443    #[must_use]
444    pub fn assistant(content: impl Into<String>) -> Self {
445        Turn {
446            role: Role::Assistant,
447            content: content.into(),
448            parts: Vec::new(),
449            tool_calls: Vec::new(),
450            tool_results: Vec::new(),
451        }
452    }
453
454    /// An assistant turn that asked to call `calls`. `content` is the text
455    /// the model wrote alongside the calls (often empty).
456    #[must_use]
457    pub fn assistant_tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
458        Turn {
459            role: Role::Assistant,
460            content: content.into(),
461            parts: Vec::new(),
462            tool_calls: calls,
463            tool_results: Vec::new(),
464        }
465    }
466
467    /// A user turn carrying the `results` of the previous assistant turn's
468    /// tool calls — the shape a provider expects the tool answers back in.
469    #[must_use]
470    pub fn tool_results(results: Vec<ToolResult>) -> Self {
471        Turn {
472            role: Role::User,
473            content: String::new(),
474            parts: Vec::new(),
475            tool_calls: Vec::new(),
476            tool_results: results,
477        }
478    }
479}
480
481/// One completion request: the tier asked for, the conversation so far, and
482/// what the caller will accept back.
483///
484/// `#[non_exhaustive]`: build one with [`Prompt::new`] and the builder
485/// methods rather than a struct literal. What a v2 port has to carry —
486/// tools, temperature, a stop sequence — should not be a breaking change
487/// for every caller, the same way [`Message`](crate::Message) is built.
488#[derive(Debug, Clone, PartialEq)]
489#[non_exhaustive]
490pub struct Prompt {
491    pub tier: ModelTier,
492    pub system: Option<String>,
493    pub messages: Vec<Turn>,
494    /// A JSON Schema (draft 2020-12) the answer should conform to. When set,
495    /// the adapter asks its provider for structured output; a provider that
496    /// honours it fills [`Completion::json`], and one that cannot leaves the
497    /// raw answer in [`Completion::text`] with `json` `None`, rather than
498    /// failing the call.
499    pub json_schema: Option<Value>,
500    pub max_tokens: u32,
501    /// The tools the model may call. Empty by default: a prompt that sends
502    /// no tools behaves exactly as it did before tools existed, and an
503    /// adapter is never asked to carry tools it was not given.
504    pub tools: Vec<ToolSpec>,
505    /// How the model should choose among [`Prompt::tools`]. `None` leaves
506    /// the choice to the provider's own default; the builder sets it.
507    pub tool_choice: Option<ToolChoice>,
508}
509
510impl Prompt {
511    /// An empty prompt for `tier`: no system prompt, no messages, no
512    /// schema, no tools, and [`DEFAULT_MAX_TOKENS`] as the ceiling.
513    /// Everything else is a builder method.
514    #[must_use]
515    pub fn new(tier: ModelTier) -> Self {
516        Self {
517            tier,
518            system: None,
519            messages: Vec::new(),
520            json_schema: None,
521            max_tokens: DEFAULT_MAX_TOKENS,
522            tools: Vec::new(),
523            tool_choice: None,
524        }
525    }
526
527    /// Offers the model one more tool it may call.
528    #[must_use]
529    pub fn tool(mut self, tool: ToolSpec) -> Self {
530        self.tools.push(tool);
531        self
532    }
533
534    /// Offers the model every tool in `tools`.
535    #[must_use]
536    pub fn tools(mut self, tools: impl IntoIterator<Item = ToolSpec>) -> Self {
537        self.tools.extend(tools);
538        self
539    }
540
541    /// Steers how the model chooses among the offered tools.
542    #[must_use]
543    pub fn tool_choice(mut self, tool_choice: ToolChoice) -> Self {
544        self.tool_choice = Some(tool_choice);
545        self
546    }
547
548    /// The standing instruction the model answers under.
549    #[must_use]
550    pub fn system(mut self, system: impl Into<String>) -> Self {
551        self.system = Some(system.into());
552        self
553    }
554
555    /// Appends a [`Turn::user`] — the common case, a one-message prompt.
556    #[must_use]
557    pub fn user(mut self, content: impl Into<String>) -> Self {
558        self.messages.push(Turn::user(content));
559        self
560    }
561
562    /// Appends a [`Turn::user_parts`] — a user message carrying text and
563    /// images in order (issue #628).
564    #[must_use]
565    pub fn user_parts(mut self, parts: impl IntoIterator<Item = Part>) -> Self {
566        self.messages.push(Turn::user_parts(parts));
567        self
568    }
569
570    /// Appends a [`Turn::assistant`].
571    #[must_use]
572    pub fn assistant(mut self, content: impl Into<String>) -> Self {
573        self.messages.push(Turn::assistant(content));
574        self
575    }
576
577    /// Appends a whole turn, for a conversation built elsewhere.
578    #[must_use]
579    pub fn turn(mut self, turn: Turn) -> Self {
580        self.messages.push(turn);
581        self
582    }
583
584    /// Asks for structured output conforming to `schema`; a successful
585    /// completion then carries the parsed value in [`Completion::json`].
586    #[must_use]
587    pub fn json_schema(mut self, schema: Value) -> Self {
588        self.json_schema = Some(schema);
589        self
590    }
591
592    /// The token ceiling for the answer.
593    #[must_use]
594    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
595        self.max_tokens = max_tokens;
596        self
597    }
598
599    /// Whether any turn carries an image [`Part`] (issue #628). The router
600    /// and the tool loop gate [`Capability::Images`] on this, exactly as
601    /// they gate [`Capability::Tools`] on [`Prompt::tools`].
602    #[must_use]
603    pub fn has_images(&self) -> bool {
604        self.messages.iter().any(|turn| {
605            turn.parts
606                .iter()
607                .any(|part| matches!(part, Part::Image { .. }))
608        })
609    }
610
611    /// Refuses a prompt whose images exceed a bound (issue #628), without
612    /// encoding anything: the count against [`MAX_PROMPT_IMAGES`], each
613    /// image's [`encoded_image_len`] against [`MAX_IMAGE_ENCODED_BYTES`],
614    /// and their sum against [`MAX_PROMPT_IMAGE_ENCODED_BYTES`].
615    ///
616    /// # Errors
617    ///
618    /// [`TextModelError::ImageLimit`], naming which bound was crossed:
619    /// [`ImageLimit::TooMany`], [`ImageLimit::ImageTooLarge`] (with the
620    /// 0-based index among the prompt's images) or
621    /// [`ImageLimit::TotalTooLarge`].
622    pub fn check_images(&self) -> Result<(), TextModelError> {
623        let count = self
624            .messages
625            .iter()
626            .flat_map(|turn| &turn.parts)
627            .filter(|part| matches!(part, Part::Image { .. }))
628            .count();
629        if count > MAX_PROMPT_IMAGES {
630            return Err(TextModelError::ImageLimit(ImageLimit::TooMany { count }));
631        }
632
633        let mut total: usize = 0;
634        let mut index: usize = 0;
635        for turn in &self.messages {
636            for part in &turn.parts {
637                let Part::Image { bytes, .. } = part else {
638                    continue;
639                };
640                let encoded_bytes = encoded_image_len(bytes.len());
641                if encoded_bytes > MAX_IMAGE_ENCODED_BYTES {
642                    return Err(TextModelError::ImageLimit(ImageLimit::ImageTooLarge {
643                        index,
644                        encoded_bytes,
645                    }));
646                }
647                total = total.saturating_add(encoded_bytes);
648                index += 1;
649            }
650        }
651        if total > MAX_PROMPT_IMAGE_ENCODED_BYTES {
652            return Err(TextModelError::ImageLimit(ImageLimit::TotalTooLarge {
653                encoded_bytes: total,
654            }));
655        }
656        Ok(())
657    }
658}
659
660/// A completed completion: the text, the model that answered (a provider
661/// identifier, for the log line), and the token usage.
662///
663/// The two token counts have a fixed relationship: [`Completion::input_tokens`]
664/// is the **total** prompt tokens the provider processed, and
665/// [`Completion::cached_input_tokens`] — where the provider reports one —
666/// is the subset of that total served from the provider's own prompt
667/// cache. A caller costing a completion never subtracts; the total already
668/// includes the cached part.
669///
670/// `#[non_exhaustive]` for the same reason [`Prompt`] is: what a provider
671/// reports back grows, and it should not break every caller.
672#[derive(Debug, Clone, PartialEq)]
673#[non_exhaustive]
674pub struct Completion {
675    pub text: String,
676    /// The parsed answer, when the prompt carried a
677    /// [`Prompt::json_schema`]. `None` when it did not, or the provider's
678    /// answer could not be parsed — in which case `text` still holds what
679    /// came back.
680    pub json: Option<Value>,
681    pub model: String,
682    /// The total prompt tokens the provider processed, cached tokens
683    /// included — a provider that reports caching reports them alongside
684    /// the uncached remainder, not inside it. The discount question is
685    /// [`Completion::cached_input_tokens`], never a smaller total here.
686    pub input_tokens: u64,
687    pub output_tokens: u64,
688    /// The subset of [`Completion::input_tokens`] the provider served from
689    /// its own prompt cache, where the provider reports one. `None` when it
690    /// does not: an absent report and a reported zero mean different things
691    /// (a vendor that does not do caching versus a cache miss), so the
692    /// option is never collapsed.
693    pub cached_input_tokens: Option<u64>,
694    /// The tools the model asked to call. Empty for a plain answer, and
695    /// empty for a prompt that offered no tools.
696    pub tool_calls: Vec<ToolCall>,
697}
698
699impl Completion {
700    /// A completion with just the text and the model that wrote it; the
701    /// usage, parsed JSON and tool calls are builder methods.
702    #[must_use]
703    pub fn new(text: impl Into<String>, model: impl Into<String>) -> Self {
704        Self {
705            text: text.into(),
706            json: None,
707            model: model.into(),
708            input_tokens: 0,
709            output_tokens: 0,
710            cached_input_tokens: None,
711            tool_calls: Vec::new(),
712        }
713    }
714
715    /// The tools the model asked to call, in the order it named them.
716    #[must_use]
717    pub fn tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
718        self.tool_calls = tool_calls;
719        self
720    }
721
722    /// The parsed structured answer, for a prompt that asked for one.
723    #[must_use]
724    pub fn json(mut self, json: Value) -> Self {
725        self.json = Some(json);
726        self
727    }
728
729    /// The token counts the provider reported.
730    #[must_use]
731    pub fn usage(mut self, input_tokens: u64, output_tokens: u64) -> Self {
732        self.input_tokens = input_tokens;
733        self.output_tokens = output_tokens;
734        self
735    }
736
737    /// The subset of the input tokens the provider served from its own
738    /// prompt cache, where the provider reports one.
739    /// [`Completion::input_tokens`] stays the total, cached included.
740    #[must_use]
741    pub fn cached_input_tokens(mut self, cached_input_tokens: u64) -> Self {
742        self.cached_input_tokens = Some(cached_input_tokens);
743        self
744    }
745}
746
747/// Why a completion stopped (issue #859): what
748/// [`TextDelta::Finish`] names when a stream ends successfully, and the
749/// shape `complete`'s outcome takes on the streaming port.
750///
751/// `#[non_exhaustive]`: providers invent reasons, and [`Self::Other`] is
752/// the pressure valve — a caller that matches the known ones and treats
753/// anything else as [`Self::Stop`]-shaped should not recompile when a
754/// provider's vocabulary grows.
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756#[serde(rename_all = "snake_case")]
757#[non_exhaustive]
758pub enum FinishReason {
759    /// The model finished its answer on its own.
760    Stop,
761    /// The answer was cut off at [`Prompt::max_tokens`].
762    Length,
763    /// The model asked to call a tool; the answer continues in
764    /// [`Completion::tool_calls`].
765    ToolUse,
766    /// The provider withheld or truncated content under its own policy.
767    ContentFilter,
768    /// A reason this port does not name. The provider's own word rides
769    /// along for the log line.
770    Other(String),
771}
772
773/// One incremental piece of a streamed completion (issue #859) — the item
774/// type of [`TextModel::stream`].
775///
776/// Semantics a caller (and an overriding adapter) can rely on:
777///
778/// - [`Self::Usage`] carries **running totals**; a stream may send several
779///   and the last one wins.
780/// - [`Self::ToolCallStarted`] / [`Self::ToolCallArguments`] /
781///   [`Self::ToolCallFinished`] carry one tool call through its life by
782///   `index`. The argument `chunk`s are incremental and only
783///   human-readable in sequence; [`Self::ToolCallFinished`] carries the
784///   fully reassembled [`ToolCall`] — exactly what `complete` would have
785///   returned in [`Completion::tool_calls`] — so a caller who only cares
786///   about finished calls can ignore the first two.
787/// - Exactly **one** [`Self::Finish`] ends a successful stream; it is the
788///   last item. An `Err` item also ends the stream (a failed completion
789///   has no `Finish`).
790/// - Dropping the stream cancels the completion: an overriding adapter
791///   MUST abort its upstream exchange on drop, the contract
792///   [`HttpClient::send_streaming`](crate::HttpClient::send_streaming)
793///   carries.
794///
795/// `Serialize`/`Deserialize` (adjacently tagged, so every variant round
796/// trips) is what lets a route write deltas straight out as SSE or
797/// NDJSON: `{"type":"text","data":"…"}`, `{"type":"finish","data":{…}}`.
798#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
799#[serde(tag = "type", content = "data", rename_all = "snake_case")]
800#[non_exhaustive]
801pub enum TextDelta {
802    /// A run of answer text, in stream order.
803    Text(String),
804    /// A run of reasoning the model surfaced alongside its answer. Not
805    /// part of [`Completion::text`]; a caller shows it or drops it.
806    Reasoning(String),
807    /// The model asked to call a tool: the call's `index` in the
808    /// completion's tool-call order, its provider-assigned `id`, and the
809    /// tool `name`. The arguments have not arrived yet.
810    ToolCallStarted {
811        /// Position this call occupies in the completion's tool calls.
812        index: usize,
813        /// The provider's id for the call.
814        id: String,
815        /// The tool being called.
816        name: String,
817    },
818    /// One run of the arguments of the call at `index`. Chunks concatenate
819    /// to the call's full arguments JSON; see [`Self::ToolCallFinished`]
820    /// for the reassembled form.
821    ToolCallArguments {
822        /// Which call's arguments are growing.
823        index: usize,
824        /// The next piece of the arguments JSON.
825        chunk: String,
826    },
827    /// The call at `index` is complete, arguments and all — the same
828    /// [`ToolCall`] `complete` would have returned.
829    ToolCallFinished {
830        /// Which call finished.
831        index: usize,
832        /// The reassembled call.
833        call: ToolCall,
834    },
835    /// Token usage so far. Running totals: a later
836    /// [`Self::Usage`](Self) supersedes it entirely.
837    Usage {
838        /// Total prompt tokens processed so far, cached included.
839        input_tokens: u64,
840        /// Completion tokens produced so far.
841        output_tokens: u64,
842        /// The cached subset of `input_tokens`, where the provider
843        /// reports one — `None` keeps "no report" and "reported zero"
844        /// distinct, exactly as [`Completion::cached_input_tokens`] does.
845        cached_input_tokens: Option<u64>,
846    },
847    /// The stream is over, successfully. Always the last item of a
848    /// successful stream, and the only one.
849    Finish {
850        /// Why the completion stopped.
851        reason: FinishReason,
852        /// The provider identifier `complete` would have reported — the
853        /// log line's model, from the stream.
854        model: String,
855    },
856}
857
858/// Decomposes a buffered [`Completion`] into the [`TextDelta`] sequence
859/// [`TextModel::stream`]'s default implementation yields: the text (if
860/// any), each tool call through its three-step life, the usage, and one
861/// `Finish` — [`FinishReason::ToolUse`] when the completion carries tool
862/// calls, [`FinishReason::Stop`] otherwise.
863///
864/// The point is one definition of "a completion, as deltas", so a default
865/// stream and a native one produce the same shape and
866/// [`CompletionBuilder`] can invert both.
867#[must_use]
868pub fn completion_deltas(completion: &Completion) -> Vec<TextDelta> {
869    let mut deltas = Vec::new();
870    if !completion.text.is_empty() {
871        deltas.push(TextDelta::Text(completion.text.clone()));
872    }
873    for (index, call) in completion.tool_calls.iter().enumerate() {
874        deltas.push(TextDelta::ToolCallStarted {
875            index,
876            id: call.id.clone(),
877            name: call.name.clone(),
878        });
879        deltas.push(TextDelta::ToolCallArguments {
880            index,
881            chunk: call.arguments.to_string(),
882        });
883        deltas.push(TextDelta::ToolCallFinished {
884            index,
885            call: call.clone(),
886        });
887    }
888    deltas.push(TextDelta::Usage {
889        input_tokens: completion.input_tokens,
890        output_tokens: completion.output_tokens,
891        cached_input_tokens: completion.cached_input_tokens,
892    });
893    let reason = if completion.tool_calls.is_empty() {
894        FinishReason::Stop
895    } else {
896        FinishReason::ToolUse
897    };
898    deltas.push(TextDelta::Finish {
899        reason,
900        model: completion.model.clone(),
901    });
902    deltas
903}
904
905/// Reassembles a stream of [`TextDelta`]s back into a [`Completion`] —
906/// the inverse of [`completion_deltas`], for a caller that consumed a
907/// stream incrementally (relaying it to a client, say) and still wants
908/// the whole answer at the end: to log, to persist, to hand to the tool
909/// loop.
910///
911/// # Semantics
912///
913/// - [`TextDelta::Text`] pieces concatenate in order.
914/// - [`TextDelta::Reasoning`] is dropped: reasoning is not part of a
915///   completion.
916/// - [`TextDelta::ToolCallFinished`] fills `tool_calls` in `index` order;
917///   argument chunks and starts are informational here, since the
918///   finished call carries the reassembled form. A call whose finish
919///   never arrived (the stream died mid-call) leaves no trace — a
920///   half-finished call is not a call the tool loop may run.
921/// - [`TextDelta::Usage`] is last-wins, whole replacement.
922/// - [`TextDelta::Finish`] supplies `model` (empty when the stream ended
923///   without one).
924/// - `json` is always `None`: structured output is a `complete` feature —
925///   the parsed value comes from validating the whole answer against the
926///   prompt's schema, which a stream that relayed itself away never
927///   promised. A streaming caller wanting a value parses and validates
928///   the reassembled text itself.
929#[derive(Debug, Default)]
930pub struct CompletionBuilder {
931    text: String,
932    tool_calls: std::collections::BTreeMap<usize, ToolCall>,
933    usage: Option<(u64, u64, Option<u64>)>,
934    model: Option<String>,
935    finish_reason: Option<FinishReason>,
936}
937
938impl CompletionBuilder {
939    /// Folds one delta in. Cheap; call it for every item the stream
940    /// yields.
941    pub fn push(&mut self, delta: &TextDelta) {
942        match delta {
943            TextDelta::Text(text) => self.text.push_str(text),
944            // Reasoning is not replayed into the completion's text, and a
945            // call only exists once its Finished delta carries it whole.
946            TextDelta::Reasoning(_)
947            | TextDelta::ToolCallStarted { .. }
948            | TextDelta::ToolCallArguments { .. } => {}
949            TextDelta::ToolCallFinished { index, call } => {
950                self.tool_calls.insert(*index, call.clone());
951            }
952            TextDelta::Usage {
953                input_tokens,
954                output_tokens,
955                cached_input_tokens,
956            } => {
957                self.usage = Some((*input_tokens, *output_tokens, *cached_input_tokens));
958            }
959            TextDelta::Finish { reason, model } => {
960                self.finish_reason = Some(reason.clone());
961                self.model = Some(model.clone());
962            }
963        }
964    }
965
966    /// Why the stream ended, where it sent a `Finish` — `None` for a
967    /// stream that is still open or died without one.
968    #[must_use]
969    pub fn finish_reason(&self) -> Option<&FinishReason> {
970        self.finish_reason.as_ref()
971    }
972
973    /// The completion the deltas add up to: text concatenated, tool calls
974    /// from their finishes in index order, usage last-wins, model from
975    /// the `Finish`, `json` `None` (see the type's docs).
976    #[must_use]
977    pub fn finish(self) -> Completion {
978        let mut completion = Completion::new(self.text, self.model.unwrap_or_default());
979        completion.tool_calls = self.tool_calls.into_values().collect();
980        if let Some((input_tokens, output_tokens, cached_input_tokens)) = self.usage {
981            completion.input_tokens = input_tokens;
982            completion.output_tokens = output_tokens;
983            completion.cached_input_tokens = cached_input_tokens;
984        }
985        completion
986    }
987}
988
989/// Which image bound a [`Prompt`] crossed (issue #628): the count, one
990/// image's encoded size, or the whole prompt's. Carries only sizes and
991/// indices — never user content — so it is safe in a log line as-is.
992///
993/// `#[non_exhaustive]`: another bound should not break every `match`.
994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995#[non_exhaustive]
996pub enum ImageLimit {
997    /// The prompt carries more than [`MAX_PROMPT_IMAGES`] images.
998    TooMany { count: usize },
999    /// The image at 0-based `index` among the prompt's images encodes to
1000    /// more than [`MAX_IMAGE_ENCODED_BYTES`] base64 bytes.
1001    ImageTooLarge { index: usize, encoded_bytes: usize },
1002    /// The prompt's images encode to more than
1003    /// [`MAX_PROMPT_IMAGE_ENCODED_BYTES`] base64 bytes together.
1004    TotalTooLarge { encoded_bytes: usize },
1005}
1006
1007impl std::fmt::Display for ImageLimit {
1008    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1009        match self {
1010            Self::TooMany { count } => write!(
1011                f,
1012                "a prompt may carry at most {MAX_PROMPT_IMAGES} images, but this one carries {count}"
1013            ),
1014            Self::ImageTooLarge {
1015                index,
1016                encoded_bytes,
1017            } => write!(
1018                f,
1019                "image {index} encodes to {encoded_bytes} bytes, over the \
1020                 {MAX_IMAGE_ENCODED_BYTES}-byte per-image ceiling"
1021            ),
1022            Self::TotalTooLarge { encoded_bytes } => write!(
1023                f,
1024                "the prompt's images encode to {encoded_bytes} bytes, over the \
1025                 {MAX_PROMPT_IMAGE_ENCODED_BYTES}-byte total"
1026            ),
1027        }
1028    }
1029}
1030
1031/// Completion failures.
1032///
1033/// [`NotConfigured`](Self::NotConfigured) sits on the **error** enum here,
1034/// unlike [`SendOutcome::NotConfigured`](crate::SendOutcome) and
1035/// [`PushOutcome::NotConfigured`](crate::PushOutcome): a completion has no
1036/// "delivered but not configured" middle state, so an unwired tier is an
1037/// error the caller matches, not an outcome it inspects.
1038///
1039/// `Transient` deliberately carries **only** `retry_after`, where
1040/// [`PushError::Transient`](crate::PushError) also carries a message: with
1041/// no provider text of its own there is nothing to scrub, and provider text
1042/// belongs on [`Rejected`](Self::Rejected) and [`Transport`](Self::Transport).
1043///
1044/// The two variants that carry provider text are sanitized in `Display`,
1045/// the same way [`PushError`](crate::PushError)'s and
1046/// [`MailError`](crate::MailError)'s are (issue #235). What an adapter wraps
1047/// is the provider's own words, and a prompt is exactly the kind of value
1048/// that rides back in them — a drafting module quotes a customer's note, and
1049/// the provider's `4xx` quotes it straight back. `Display` therefore runs it
1050/// through [`crate::logging::scrub_text`]; `Debug` still shows the raw
1051/// string for tests.
1052///
1053/// `#[non_exhaustive]`: a port's failure set grows with its shape, and
1054/// [`Unsupported`](Self::Unsupported) is exactly such a growth — a caller
1055/// that matches the variants it knows and treats anything else as a
1056/// transport failure should not have to recompile when one is added.
1057#[derive(Debug, Clone, PartialEq, Eq)]
1058#[non_exhaustive]
1059pub enum TextModelError {
1060    /// The tier asked for has no adapter — the venture did not wire it.
1061    /// Nothing is wrong with the prompt: a module may degrade, the way it
1062    /// degrades on a `NotConfigured` mailer, and
1063    /// [`RoutingTextModel`] answers this for every tier it has no
1064    /// adapter for.
1065    NotConfigured,
1066    /// The provider refused the request (a `4xx`), or refused the prompt's
1067    /// content or schema; not retryable without a change.
1068    Rejected(String),
1069    /// A transient failure (a `5xx`, a `429`, a transport error): retry
1070    /// later, and not before `retry_after` when the provider named one.
1071    /// Carries no message — provider text belongs on
1072    /// [`Rejected`](Self::Rejected) and [`Transport`](Self::Transport).
1073    Transient { retry_after: Option<Duration> },
1074    /// The request never completed as a conversation — the adapter could
1075    /// not reach the provider, or the answer did not survive the hop.
1076    Transport(String),
1077    /// The model behind this tier cannot do what the prompt asked for —
1078    /// a prompt carrying [`Prompt::tools`] or image [`Part`]s reached an
1079    /// adapter whose [`TextModel::supports`] is false for
1080    /// [`Capability::Tools`] or [`Capability::Images`], so the adapter was
1081    /// never called. A fixed sentence, never provider text, so there is
1082    /// nothing to scrub.
1083    Unsupported(Capability),
1084    /// The schema itself cannot be honoured: it uses a JSON Schema keyword
1085    /// the built-in validator does not implement (a `$ref`, a `pattern`,
1086    /// an `if`), or is malformed. This is a **caller bug** — the schema was
1087    /// written by the caller, not the model — so the model was never
1088    /// called. Raised by
1089    /// [`TextModelExt::complete_json`](crate::TextModelExt::complete_json)
1090    /// before the first request.
1091    InvalidSchema(String),
1092    /// The model's answer did not conform to the required schema, after the
1093    /// one repair retry
1094    /// [`TextModelExt::complete_json`](crate::TextModelExt::complete_json)
1095    /// allows. Carries only the schema path and reason the answer failed
1096    /// (for example `the property "price" is invalid: …`) — **never the
1097    /// model's own output**, which may hold the personal data the schema
1098    /// was there to structure.
1099    SchemaViolation(String),
1100    /// The prompt's images crossed a bound [`Prompt::check_images`]
1101    /// enforces: too many, one too large, or too large together.
1102    ImageLimit(ImageLimit),
1103}
1104
1105impl std::fmt::Display for TextModelError {
1106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1107        let scrub = crate::logging::scrub_text;
1108        match self {
1109            Self::NotConfigured => f.write_str("no text model is wired for this tier"),
1110            Self::Rejected(message) => write!(f, "completion rejected: {}", scrub(message)),
1111            Self::Transient { .. } => f.write_str("completion failed, retryable"),
1112            Self::Transport(message) => {
1113                write!(f, "completion transport failed: {}", scrub(message))
1114            }
1115            Self::Unsupported(capability) => {
1116                write!(f, "the text model does not support {capability}")
1117            }
1118            Self::InvalidSchema(reason) => {
1119                write!(f, "the JSON schema cannot be honoured: {}", scrub(reason))
1120            }
1121            Self::SchemaViolation(reason) => write!(
1122                f,
1123                "the model's answer did not match the schema: {}",
1124                scrub(reason)
1125            ),
1126            Self::ImageLimit(limit) => write!(f, "the prompt's images exceed a limit: {limit}"),
1127        }
1128    }
1129}
1130
1131impl std::error::Error for TextModelError {}
1132
1133impl TextModelError {
1134    /// How long the provider asked the caller to wait, where it said. An
1135    /// [`Unsupported`](Self::Unsupported) capability is not a back-off: no
1136    /// wait makes a model grow a capability it lacks.
1137    pub fn retry_after(&self) -> Option<Duration> {
1138        match self {
1139            TextModelError::Transient { retry_after } => *retry_after,
1140            _ => None,
1141        }
1142    }
1143}
1144
1145/// Completes a prompt, over whichever provider the venture wired for the
1146/// [`Prompt::tier`] it was asked for. An adapter serves any tier it is
1147/// configured for (typically one); [`RoutingTextModel`] dispatches between
1148/// the adapters a venture configured.
1149#[async_trait]
1150pub trait TextModel: Send + Sync {
1151    /// Completes `prompt`.
1152    ///
1153    /// [`Prompt::json_schema`] is a request: an adapter whose provider
1154    /// cannot honour structured output answers with plain text in
1155    /// [`Completion::text`] and `None` in [`Completion::json`], rather than
1156    /// failing the call.
1157    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError>;
1158
1159    /// Streams `prompt` as [`TextDelta`]s (issue #859). The default
1160    /// implementation makes one `complete` round trip — the same provider
1161    /// exchange, the same cost — and answers its result decomposed by
1162    /// [`completion_deltas`], so an adapter written before this method
1163    /// existed streams without a change. An adapter whose provider
1164    /// streams natively overrides it; a caller cannot tell the difference
1165    /// except by timing (and by [`Completion::json`], which only a
1166    /// buffered structured-output call can fill).
1167    ///
1168    /// # Cancellation
1169    ///
1170    /// Dropping the returned stream cancels the completion: an overriding
1171    /// adapter MUST abort its upstream exchange on drop, the same
1172    /// contract
1173    /// [`HttpClient::send_streaming`](crate::HttpClient::send_streaming)
1174    /// carries, so a route whose client disconnects stops the spend. A
1175    /// route handler that needs a `'static` stream builds one with
1176    /// [`stream_owned`].
1177    ///
1178    /// A successful stream ends with exactly one [`TextDelta::Finish`];
1179    /// an `Err` item ends it instead, and no `Finish` follows an error.
1180    fn stream<'a>(
1181        &'a self,
1182        prompt: &'a Prompt,
1183    ) -> BoxStream<'a, Result<TextDelta, TextModelError>> {
1184        Box::pin(
1185            futures_util::stream::once(self.complete(prompt)).flat_map(|outcome| {
1186                futures_util::stream::iter(match outcome {
1187                    Ok(completion) => completion_deltas(&completion)
1188                        .into_iter()
1189                        .map(Ok)
1190                        .collect::<Vec<_>>(),
1191                    Err(err) => vec![Err(err)],
1192                })
1193            }),
1194        )
1195    }
1196
1197    /// Whether this adapter can do `capability` for a prompt routed to
1198    /// `tier`. Defaults to `false`: a model that has not opted in is
1199    /// refused a tools-bearing prompt by [`RoutingTextModel`] and
1200    /// [`run_tool_loop`](crate::run_tool_loop) before it is ever called,
1201    /// rather than sent a request it would silently drop the tools from.
1202    /// An adapter that carries [`Capability::Tools`] or
1203    /// [`Capability::Images`] overrides this to answer `true` for it.
1204    ///
1205    /// `tier` is the router's own routing key, not the adapter's: an
1206    /// adapter serves whatever tier it was wired for and is free to ignore
1207    /// it — an adapter only honours `tier` if the same deployment could be
1208    /// wired for two tiers with different capabilities. The parameter is
1209    /// on the method so a caller never has to ask the router which model a
1210    /// tier holds just to ask what it can do.
1211    fn supports(&self, _tier: ModelTier, _capability: Capability) -> bool {
1212        false
1213    }
1214}
1215
1216/// A `'static` stream of `model`'s deltas for `prompt` (issue #859): the
1217/// shape a route handler needs, because
1218/// [`ResponseStream::new`](crate::ResponseStream::new) wants a body that
1219/// outlives the request's borrowed state and [`TextModel::stream`] wants
1220/// borrows.
1221///
1222/// Implemented without spawning and without `unsafe`: the model and the
1223/// prompt travel in a pump future merged into the output stream (the
1224/// crate-internal `pump` helper in core's `stream` module does the
1225/// merging), so the pump is polled exactly when the output is and
1226/// **dropping the output cancels the completion** — the inner stream
1227/// drops, and with it the upstream exchange, which is how a disconnecting
1228/// client stops the spend. An item the receiver has no room for parks the
1229/// pump; a receiver that went away ends it.
1230pub fn stream_owned(
1231    model: Arc<dyn TextModel>,
1232    prompt: Prompt,
1233) -> BoxStream<'static, Result<TextDelta, TextModelError>> {
1234    pump(move |mut tx| async move {
1235        let mut stream = model.stream(&prompt);
1236        while let Some(delta) = stream.next().await {
1237            if tx.send(delta).await.is_err() {
1238                // The consumer is gone: dropping the loop drops the inner
1239                // stream and with it the upstream exchange.
1240                break;
1241            }
1242        }
1243    })
1244}
1245
1246/// Dispatches by [`Prompt::tier`] to the adapter a venture configured for
1247/// that tier, so venture code holds one `Arc<dyn TextModel>` and never
1248/// matches on the tier or the vendor itself (ADR 0002). The router lives
1249/// above the adapters, in core — the same place
1250/// [`RoutingPush`](crate::RoutingPush) does, and for the same reason
1251/// (ADR 0015).
1252///
1253/// This is the seam that lets a venture put drafting on one vendor and an
1254/// independent judge on another without either module knowing.
1255///
1256/// A tier with no adapter is [`TextModelError::NotConfigured`] —
1257/// deliberately not [`TextModelError::Rejected`]: nothing is wrong with the
1258/// prompt, the venture simply did not wire that tier.
1259///
1260/// The router also enforces capability agreement (issue #665): a prompt
1261/// that carries [`Prompt::tools`] is refused with
1262/// [`TextModelError::Unsupported`] before a tier that does not report
1263/// [`Capability::Tools`] for that tier is called, so a venture can assert
1264/// once at compose time — `router.supports(ModelTier::Strong,
1265/// Capability::Tools)` — that the tier it routed tools to can carry them.
1266/// [`RoutingTextModel::supports`] answers for the single routed tier: the
1267/// adapter wired for `tier`, or `false` when that tier is unwired. It is
1268/// deliberately **not** an aggregate over every wired tier: a prompt goes
1269/// to exactly one tier, so what matters is that tier's answer, and a
1270/// strong-tier judge that cannot carry tools must not make a fast-tier
1271/// drafting model look incapable.
1272///
1273/// The same agreement covers images (issue #628): a prompt carrying a
1274/// [`Part::Image`] is refused with [`TextModelError::Unsupported`] before a
1275/// tier that does not report [`Capability::Images`] is called, and an
1276/// over-limit prompt by [`Prompt::check_images`] before the tier is looked
1277/// up at all.
1278#[derive(Default, Clone)]
1279pub struct RoutingTextModel {
1280    fast: Option<Arc<dyn TextModel>>,
1281    strong: Option<Arc<dyn TextModel>>,
1282}
1283
1284impl RoutingTextModel {
1285    /// A router with no adapters: every tier is `NotConfigured`.
1286    #[must_use]
1287    pub fn new() -> Self {
1288        Self::default()
1289    }
1290
1291    /// The adapter for [`ModelTier::Fast`].
1292    #[must_use]
1293    pub fn fast(mut self, model: Arc<dyn TextModel>) -> Self {
1294        self.fast = Some(model);
1295        self
1296    }
1297
1298    /// The adapter for [`ModelTier::Strong`].
1299    #[must_use]
1300    pub fn strong(mut self, model: Arc<dyn TextModel>) -> Self {
1301        self.strong = Some(model);
1302        self
1303    }
1304
1305    /// The adapter that serves `tier`, if one is configured.
1306    #[must_use]
1307    pub fn route_for(&self, tier: ModelTier) -> Option<&Arc<dyn TextModel>> {
1308        match tier {
1309            ModelTier::Fast => self.fast.as_ref(),
1310            ModelTier::Strong => self.strong.as_ref(),
1311        }
1312    }
1313
1314    /// The adapter `prompt` routes to, after the pre-flight refusals both
1315    /// halves of the port share — `complete` and `stream` run the same
1316    /// gates in the same order, so a prompt refused on one half is
1317    /// refused identically, and before any adapter is called, on the
1318    /// other: image bounds first (they hold whatever tier answers), then
1319    /// the tier lookup, then the per-tier capability agreement.
1320    fn routed(&self, prompt: &Prompt) -> Result<&Arc<dyn TextModel>, TextModelError> {
1321        // An over-limit image prompt is refused before anything is routed:
1322        // the limits are about the request a provider would be handed, so
1323        // they hold whatever tier answers.
1324        prompt.check_images()?;
1325        let model = self
1326            .route_for(prompt.tier)
1327            .ok_or(TextModelError::NotConfigured)?;
1328        // A tools-bearing prompt to a model that cannot carry tools is
1329        // refused here, not silently sent with the tools dropped: the
1330        // caller asked for something the wiring cannot honour, and a
1331        // fabricated plain answer would hide that.
1332        if !prompt.tools.is_empty() && !model.supports(prompt.tier, Capability::Tools) {
1333            return Err(TextModelError::Unsupported(Capability::Tools));
1334        }
1335        // The same rule for images: a prompt carrying one to a model
1336        // without vision is refused, never sent with the image silently
1337        // dropped.
1338        if prompt.has_images() && !model.supports(prompt.tier, Capability::Images) {
1339            return Err(TextModelError::Unsupported(Capability::Images));
1340        }
1341        Ok(model)
1342    }
1343}
1344
1345impl std::fmt::Debug for RoutingTextModel {
1346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1347        f.debug_struct("RoutingTextModel")
1348            .field("fast", &self.fast.is_some())
1349            .field("strong", &self.strong.is_some())
1350            .finish()
1351    }
1352}
1353
1354#[async_trait]
1355impl TextModel for RoutingTextModel {
1356    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
1357        let model = self.routed(prompt)?;
1358        model.complete(prompt).await
1359    }
1360
1361    /// Forwards to the routed tier's own `stream` — a native stream is
1362    /// not flattened through a buffered `complete`, or the router would
1363    /// turn every native stream into one round trip. The pre-flight
1364    /// refusals are the router's own (`routed`), so an unwired tier, an
1365    /// over-limit prompt or a capability the tier lacks fails with the
1366    /// same error `complete` gives, as the stream's first item.
1367    fn stream<'a>(
1368        &'a self,
1369        prompt: &'a Prompt,
1370    ) -> BoxStream<'a, Result<TextDelta, TextModelError>> {
1371        match self.routed(prompt) {
1372            Ok(model) => model.stream(prompt),
1373            Err(err) => Box::pin(futures_util::stream::iter([Err(err)])),
1374        }
1375    }
1376
1377    fn supports(&self, tier: ModelTier, capability: Capability) -> bool {
1378        // The routed tier's own answer, and `false` for an unwired tier:
1379        // nothing is wired to do anything. A prompt goes to one tier, so
1380        // this is the tier that must be able to carry the capability — not
1381        // an aggregate over the tiers the router happens to hold.
1382        self.route_for(tier)
1383            .is_some_and(|model| model.supports(tier, capability))
1384    }
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390    use futures_core::Stream;
1391    use std::pin::Pin;
1392    use std::sync::atomic::{AtomicUsize, Ordering};
1393    use std::task::{Context, Poll};
1394
1395    // -----------------------------------------------------------------
1396    // ModelTier, Role, and the wire form
1397
1398    #[test]
1399    fn a_tier_and_a_role_round_trip_through_json() {
1400        for tier in [ModelTier::Fast, ModelTier::Strong] {
1401            let json = serde_json::to_string(&tier).expect("serialises");
1402            let back: ModelTier = serde_json::from_str(&json).expect("deserialises");
1403            assert_eq!(tier, back);
1404        }
1405        for role in [Role::User, Role::Assistant] {
1406            let json = serde_json::to_string(&role).expect("serialises");
1407            let back: Role = serde_json::from_str(&json).expect("deserialises");
1408            assert_eq!(role, back);
1409        }
1410    }
1411
1412    #[test]
1413    fn the_wire_form_is_snake_case() {
1414        // The point of `rename_all`: the persisted name is the prose name,
1415        // so a config file reads `"strong"`, not `"Strong"`.
1416        assert_eq!(serde_json::to_value(ModelTier::Strong).unwrap(), "strong");
1417        assert_eq!(serde_json::to_value(Role::User).unwrap(), "user");
1418    }
1419
1420    #[test]
1421    fn the_tier_names_itself_for_logs_and_errors() {
1422        assert_eq!(ModelTier::Fast.name(), "fast");
1423        assert_eq!(ModelTier::Strong.to_string(), "strong");
1424        assert_eq!(Role::Assistant.name(), "assistant");
1425        assert_eq!(Role::User.to_string(), "user");
1426    }
1427
1428    // -----------------------------------------------------------------
1429    // Prompt and Completion builders
1430
1431    #[test]
1432    fn a_prompt_starts_empty_and_builds_up() {
1433        let prompt = Prompt::new(ModelTier::Fast);
1434        assert_eq!(prompt.tier, ModelTier::Fast);
1435        assert_eq!(prompt.system, None);
1436        assert!(prompt.messages.is_empty());
1437        assert_eq!(prompt.json_schema, None);
1438        assert_eq!(prompt.max_tokens, DEFAULT_MAX_TOKENS);
1439        assert_eq!(DEFAULT_MAX_TOKENS, 1024);
1440
1441        let prompt = prompt
1442            .system("Draft the reply.")
1443            .user("Hello")
1444            .assistant("Hi there")
1445            .turn(Turn::user("And again"))
1446            .json_schema(serde_json::json!({ "type": "object" }))
1447            .max_tokens(256);
1448
1449        assert_eq!(prompt.system.as_deref(), Some("Draft the reply."));
1450        assert_eq!(
1451            prompt.messages,
1452            vec![
1453                Turn::user("Hello"),
1454                Turn::assistant("Hi there"),
1455                Turn::user("And again"),
1456            ]
1457        );
1458        assert_eq!(
1459            prompt.json_schema,
1460            Some(serde_json::json!({ "type": "object" }))
1461        );
1462        assert_eq!(prompt.max_tokens, 256);
1463    }
1464
1465    #[test]
1466    fn a_completion_starts_with_text_and_model_and_builds_up() {
1467        let completion = Completion::new("the answer", "vendor-1");
1468        assert_eq!(completion.text, "the answer");
1469        assert_eq!(completion.model, "vendor-1");
1470        assert_eq!(completion.json, None);
1471        assert_eq!(completion.input_tokens, 0);
1472        assert_eq!(completion.output_tokens, 0);
1473
1474        let completion = completion
1475            .json(serde_json::json!({ "reply": "the answer" }))
1476            .usage(12, 34);
1477        assert_eq!(
1478            completion.json,
1479            Some(serde_json::json!({ "reply": "the answer" }))
1480        );
1481        assert_eq!(completion.input_tokens, 12);
1482        assert_eq!(completion.output_tokens, 34);
1483    }
1484
1485    #[test]
1486    fn cached_input_tokens_defaults_to_absent_and_never_shrinks_the_total() {
1487        // A vendor that says nothing about caching stays `None` — a
1488        // reported zero (a cache miss) and an absent report (a vendor
1489        // without caching) are different facts.
1490        let completion = Completion::new("the answer", "vendor-1");
1491        assert_eq!(completion.cached_input_tokens, None);
1492
1493        // The builder records the cache read without shrinking the total:
1494        // `input_tokens` is what the vendor processed, cached included.
1495        let completion = Completion::new("the answer", "vendor-1")
1496            .usage(17, 5)
1497            .cached_input_tokens(9);
1498        assert_eq!(completion.input_tokens, 17);
1499        assert_eq!(completion.cached_input_tokens, Some(9));
1500    }
1501
1502    // -----------------------------------------------------------------
1503    // TextModelError
1504
1505    #[test]
1506    fn display_sanitizes_the_provider_text() {
1507        // Issue #235. The text an adapter wraps is the provider's own
1508        // words, and a drafting prompt quotes whatever a user wrote: what
1509        // the provider echoes back must not survive into a log line or a
1510        // dead-letter row carrying its URLs, tokens or addresses.
1511        let error = TextModelError::Rejected(
1512            "provider 400 for https://api.example.test/v1/complete?token=secret-abcdef".to_owned(),
1513        );
1514        let text = error.to_string();
1515        assert!(text.contains("completion rejected"), "{text}");
1516        assert!(!text.contains("secret-abcdef"), "{text}");
1517        assert!(text.contains("?[redacted]"), "{text}");
1518
1519        let error = TextModelError::Transport("timeout quoting alice@example.test".to_owned());
1520        let text = error.to_string();
1521        assert!(text.contains("completion transport failed"), "{text}");
1522        assert!(!text.contains('@'), "{text}");
1523
1524        // `Debug` still shows the raw string for a failing test to read.
1525        assert!(format!("{error:?}").contains("alice@example.test"));
1526    }
1527
1528    #[test]
1529    fn transient_says_retryable_and_carries_no_text_to_scrub() {
1530        // `Transient` has no message field on purpose — the fixed sentence
1531        // is the whole `Display`, and a provider's words would be an
1532        // unsanitised leak by construction.
1533        let error = TextModelError::Transient {
1534            retry_after: Some(Duration::from_secs(30)),
1535        };
1536        assert_eq!(error.to_string(), "completion failed, retryable");
1537    }
1538
1539    #[test]
1540    fn transient_carries_an_optional_retry_after() {
1541        let throttled = TextModelError::Transient {
1542            retry_after: Some(Duration::from_secs(30)),
1543        };
1544        assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
1545        let plain = TextModelError::Transient { retry_after: None };
1546        assert_eq!(plain.retry_after(), None);
1547        assert_eq!(TextModelError::NotConfigured.retry_after(), None);
1548        assert_eq!(
1549            TextModelError::Rejected("422".to_owned()).retry_after(),
1550            None,
1551            "a rejection is not a back-off"
1552        );
1553        assert_eq!(
1554            TextModelError::Transport("timed out".to_owned()).retry_after(),
1555            None
1556        );
1557    }
1558
1559    #[test]
1560    fn not_configured_names_the_missing_wiring() {
1561        assert_eq!(
1562            TextModelError::NotConfigured.to_string(),
1563            "no text model is wired for this tier"
1564        );
1565    }
1566
1567    #[test]
1568    fn the_structured_output_failures_name_themselves_and_never_back_off() {
1569        let invalid =
1570            TextModelError::InvalidSchema("the `pattern` keyword is not supported".to_owned());
1571        assert_eq!(
1572            invalid.to_string(),
1573            "the JSON schema cannot be honoured: the `pattern` keyword is not supported"
1574        );
1575        let violation =
1576            TextModelError::SchemaViolation("the property \"price\" is invalid".to_owned());
1577        assert!(
1578            violation.to_string().contains("did not match the schema"),
1579            "{violation}"
1580        );
1581        assert_eq!(invalid.retry_after(), None);
1582        assert_eq!(violation.retry_after(), None);
1583    }
1584
1585    // -----------------------------------------------------------------
1586    // RoutingTextModel
1587
1588    struct Recording {
1589        label: &'static str,
1590        tools: bool,
1591        images: bool,
1592        seen: std::sync::atomic::AtomicUsize,
1593    }
1594
1595    impl Recording {
1596        /// A model that reports no capability — the default for an adapter
1597        /// that has not opted into tools.
1598        fn new(label: &'static str) -> Arc<Self> {
1599            Arc::new(Self {
1600                label,
1601                tools: false,
1602                images: false,
1603                seen: std::sync::atomic::AtomicUsize::new(0),
1604            })
1605        }
1606
1607        /// A model that reports [`Capability::Tools`].
1608        fn tool_capable(label: &'static str) -> Arc<Self> {
1609            Arc::new(Self {
1610                label,
1611                tools: true,
1612                images: false,
1613                seen: std::sync::atomic::AtomicUsize::new(0),
1614            })
1615        }
1616
1617        /// A model that reports [`Capability::Images`].
1618        fn image_capable(label: &'static str) -> Arc<Self> {
1619            Arc::new(Self {
1620                label,
1621                tools: false,
1622                images: true,
1623                seen: std::sync::atomic::AtomicUsize::new(0),
1624            })
1625        }
1626
1627        fn count(&self) -> usize {
1628            self.seen.load(std::sync::atomic::Ordering::Relaxed)
1629        }
1630    }
1631
1632    #[async_trait]
1633    impl TextModel for Recording {
1634        async fn complete(&self, _prompt: &Prompt) -> Result<Completion, TextModelError> {
1635            self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1636            Ok(Completion::new("recorded", self.label))
1637        }
1638
1639        fn supports(&self, _tier: ModelTier, capability: Capability) -> bool {
1640            (capability == Capability::Tools && self.tools)
1641                || (capability == Capability::Images && self.images)
1642        }
1643    }
1644
1645    #[test]
1646    fn the_router_dispatches_by_tier() {
1647        let fast = Recording::new("fast-vendor");
1648        let strong = Recording::new("strong-vendor");
1649        let router = RoutingTextModel::new()
1650            .fast(fast.clone())
1651            .strong(strong.clone());
1652
1653        let completion =
1654            pollster::block_on(router.complete(&Prompt::new(ModelTier::Fast).user("hi"))).unwrap();
1655        assert_eq!(
1656            completion.model, "fast-vendor",
1657            "the fast tier reached its adapter"
1658        );
1659        let completion =
1660            pollster::block_on(router.complete(&Prompt::new(ModelTier::Strong).user("hi")))
1661                .unwrap();
1662        assert_eq!(
1663            completion.model, "strong-vendor",
1664            "the strong tier reached its own, different adapter"
1665        );
1666        assert_eq!(fast.count(), 1);
1667        assert_eq!(strong.count(), 1);
1668    }
1669
1670    #[test]
1671    fn a_tier_with_no_adapter_is_not_configured_not_rejected() {
1672        let router = RoutingTextModel::new().fast(Recording::new("fast-vendor"));
1673        let error = pollster::block_on(router.complete(&Prompt::new(ModelTier::Strong).user("hi")))
1674            .unwrap_err();
1675        assert_eq!(error, TextModelError::NotConfigured);
1676        assert!(router.route_for(ModelTier::Strong).is_none());
1677    }
1678
1679    #[test]
1680    fn an_empty_router_is_not_configured_for_every_tier() {
1681        let router = RoutingTextModel::new();
1682        for tier in [ModelTier::Fast, ModelTier::Strong] {
1683            let error =
1684                pollster::block_on(router.complete(&Prompt::new(tier).user("hi"))).unwrap_err();
1685            assert_eq!(error, TextModelError::NotConfigured);
1686            assert!(router.route_for(tier).is_none());
1687        }
1688    }
1689
1690    #[test]
1691    fn debug_prints_which_tiers_are_wired_and_nothing_else() {
1692        // The adapters themselves are `Arc<dyn TextModel>` and have no
1693        // meaningful `Debug`; printing their presence is the whole report.
1694        let router = RoutingTextModel::new().fast(Recording::new("fast-vendor"));
1695        let printed = format!("{router:?}");
1696        assert!(printed.contains("RoutingTextModel"), "{printed}");
1697        assert!(printed.contains("fast: true"), "{printed}");
1698        assert!(printed.contains("strong: false"), "{printed}");
1699    }
1700
1701    // -----------------------------------------------------------------
1702    // Tools (issue #665)
1703
1704    #[test]
1705    fn a_capability_names_itself_for_logs_and_errors() {
1706        assert_eq!(Capability::Tools.name(), "tools");
1707        assert_eq!(Capability::Tools.to_string(), "tools");
1708        assert_eq!(Capability::Images.name(), "images");
1709        assert_eq!(Capability::Images.to_string(), "images");
1710    }
1711
1712    #[test]
1713    fn a_tool_spec_carries_its_name_description_and_schema() {
1714        let tool = ToolSpec::new(
1715            "lookup",
1716            "Look a thing up.",
1717            serde_json::json!({ "type": "object" }),
1718        );
1719        assert_eq!(tool.name, "lookup");
1720        assert_eq!(tool.description, "Look a thing up.");
1721        assert_eq!(tool.parameters, serde_json::json!({ "type": "object" }));
1722    }
1723
1724    #[test]
1725    fn a_tool_call_and_result_round_trip_their_fields() {
1726        let call = ToolCall::new("call-1", "lookup", serde_json::json!({ "q": "x" }));
1727        assert_eq!(call.id, "call-1");
1728        assert_eq!(call.name, "lookup");
1729        assert_eq!(call.arguments, serde_json::json!({ "q": "x" }));
1730
1731        let ok = ToolResult::ok("call-1", "the answer");
1732        assert_eq!(ok.tool_call_id, "call-1");
1733        assert_eq!(ok.content, "the answer");
1734        assert!(!ok.is_error);
1735
1736        let error = ToolResult::error("call-1", "it broke");
1737        assert!(error.is_error);
1738        assert_eq!(error.content, "it broke");
1739    }
1740
1741    #[test]
1742    fn a_turn_can_quote_tool_calls_and_carry_results_back() {
1743        let call = ToolCall::new("call-1", "lookup", serde_json::json!({}));
1744        let assistant = Turn::assistant_tool_calls("thinking", vec![call.clone()]);
1745        assert_eq!(assistant.role, Role::Assistant);
1746        assert_eq!(assistant.content, "thinking");
1747        assert_eq!(assistant.tool_calls, vec![call]);
1748        assert!(assistant.tool_results.is_empty());
1749
1750        let results = Turn::tool_results(vec![ToolResult::ok("call-1", "fine")]);
1751        assert_eq!(results.role, Role::User);
1752        assert_eq!(results.content, "");
1753        assert!(results.tool_calls.is_empty());
1754        assert_eq!(results.tool_results.len(), 1);
1755
1756        // The plain constructors still carry neither list, so an existing
1757        // caller that builds a prompt with them is unaffected.
1758        assert!(Turn::user("hi").tool_calls.is_empty());
1759        assert!(Turn::assistant("hi").tool_results.is_empty());
1760    }
1761
1762    #[test]
1763    fn a_prompt_starts_without_tools_and_builds_them_up() {
1764        let prompt = Prompt::new(ModelTier::Fast);
1765        assert!(prompt.tools.is_empty());
1766        assert_eq!(prompt.tool_choice, None);
1767
1768        let prompt = prompt
1769            .tool(ToolSpec::new("a", "A", serde_json::json!({})))
1770            .tools(vec![
1771                ToolSpec::new("b", "B", serde_json::json!({})),
1772                ToolSpec::new("c", "C", serde_json::json!({})),
1773            ])
1774            .tool_choice(ToolChoice::Required);
1775        assert_eq!(prompt.tools.len(), 3);
1776        assert_eq!(
1777            prompt
1778                .tools
1779                .iter()
1780                .map(|tool| tool.name.as_str())
1781                .collect::<Vec<_>>(),
1782            vec!["a", "b", "c"]
1783        );
1784        assert_eq!(prompt.tool_choice, Some(ToolChoice::Required));
1785    }
1786
1787    #[test]
1788    fn a_completion_starts_without_tool_calls_and_builds_them_up() {
1789        let completion = Completion::new("the answer", "vendor-1");
1790        assert!(completion.tool_calls.is_empty());
1791        let completion = completion.tool_calls(vec![ToolCall::new(
1792            "call-1",
1793            "lookup",
1794            serde_json::json!({}),
1795        )]);
1796        assert_eq!(completion.tool_calls.len(), 1);
1797    }
1798
1799    #[test]
1800    fn an_unsupported_capability_names_what_is_missing_and_never_backs_off() {
1801        let error = TextModelError::Unsupported(Capability::Tools);
1802        assert_eq!(error.to_string(), "the text model does not support tools");
1803        assert_eq!(error.retry_after(), None);
1804    }
1805
1806    #[test]
1807    fn the_default_capability_answer_is_no() {
1808        // An adapter that has not opted into tools reports none, so the
1809        // router and the tool loop refuse a tools-bearing prompt before it
1810        // is called. An adapter ignores the tier it is asked about.
1811        assert!(!Recording::new("plain").supports(ModelTier::Fast, Capability::Tools));
1812        assert!(Recording::tool_capable("capable").supports(ModelTier::Strong, Capability::Tools));
1813    }
1814
1815    #[test]
1816    fn tools_to_a_tier_that_cannot_carry_them_are_refused_before_the_call() {
1817        let fast = Recording::new("fast-vendor");
1818        let router = RoutingTextModel::new().fast(fast.clone());
1819        let prompt = Prompt::new(ModelTier::Fast).user("hi").tool(ToolSpec::new(
1820            "lookup",
1821            "Look up.",
1822            serde_json::json!({}),
1823        ));
1824
1825        let error = pollster::block_on(router.complete(&prompt)).unwrap_err();
1826        assert_eq!(error, TextModelError::Unsupported(Capability::Tools));
1827        assert_eq!(
1828            fast.count(),
1829            0,
1830            "the adapter was never called, tools and all"
1831        );
1832    }
1833
1834    #[test]
1835    fn tools_to_a_capable_tier_pass_through_untouched() {
1836        let fast = Recording::tool_capable("fast-vendor");
1837        let router = RoutingTextModel::new().fast(fast.clone());
1838        let prompt = Prompt::new(ModelTier::Fast).tool(ToolSpec::new(
1839            "lookup",
1840            "Look up.",
1841            serde_json::json!({}),
1842        ));
1843
1844        let completion = pollster::block_on(router.complete(&prompt)).unwrap();
1845        assert_eq!(completion.model, "fast-vendor");
1846        assert_eq!(fast.count(), 1);
1847    }
1848
1849    #[test]
1850    fn a_prompt_without_tools_reaches_a_plain_model_as_before() {
1851        // The capability gate is only for tools: a prompt carrying none is
1852        // served by a model that reports no capabilities, unchanged.
1853        let fast = Recording::new("fast-vendor");
1854        let router = RoutingTextModel::new().fast(fast.clone());
1855        let completion =
1856            pollster::block_on(router.complete(&Prompt::new(ModelTier::Fast).user("hi"))).unwrap();
1857        assert_eq!(completion.model, "fast-vendor");
1858        assert_eq!(fast.count(), 1);
1859    }
1860
1861    #[test]
1862    fn the_router_answers_a_capability_per_tier_and_false_when_unwired() {
1863        let capable = Recording::tool_capable("capable");
1864        let plain = Recording::new("plain");
1865        let router = RoutingTextModel::new()
1866            .fast(capable.clone())
1867            .strong(plain.clone());
1868
1869        // The routed tier's own answer: a prompt goes to one tier, so a
1870        // capable fast tier reads capable even when the strong tier is not,
1871        // and vice versa.
1872        assert!(router.supports(ModelTier::Fast, Capability::Tools));
1873        assert!(!router.supports(ModelTier::Strong, Capability::Tools));
1874
1875        // Nothing wired for a tier: no capability, whatever the other tier
1876        // holds.
1877        let unwired = RoutingTextModel::new();
1878        assert!(!unwired.supports(ModelTier::Fast, Capability::Tools));
1879        assert!(!unwired.supports(ModelTier::Strong, Capability::Tools));
1880    }
1881
1882    // -----------------------------------------------------------------
1883    // Images (issue #628)
1884
1885    /// A raw byte length whose standard, padded base64 encoding is exactly
1886    /// `encoded`, for `encoded` a multiple of four.
1887    fn raw_len_for_encoded(encoded: usize) -> usize {
1888        (encoded / 4) * 3
1889    }
1890
1891    #[test]
1892    fn an_image_media_type_names_its_wire_mime() {
1893        assert_eq!(ImageMediaType::Png.as_str(), "image/png");
1894        assert_eq!(ImageMediaType::Jpeg.as_str(), "image/jpeg");
1895        assert_eq!(ImageMediaType::Gif.as_str(), "image/gif");
1896        assert_eq!(ImageMediaType::Webp.as_str(), "image/webp");
1897    }
1898
1899    #[test]
1900    fn encode_image_is_standard_padded_base64_and_encoded_image_len_predicts_it() {
1901        // The canonical RFC 4648 test vectors, padded.
1902        assert_eq!(encode_image(b""), "");
1903        assert_eq!(encode_image(b"f"), "Zg==");
1904        assert_eq!(encode_image(b"fo"), "Zm8=");
1905        assert_eq!(encode_image(b"foo"), "Zm9v");
1906        assert_eq!(encode_image(b"foob"), "Zm9vYg==");
1907        assert_eq!(encode_image(b"fooba"), "Zm9vYmE=");
1908        assert_eq!(encode_image(b"foobar"), "Zm9vYmFy");
1909
1910        // `encoded_image_len` is pure arithmetic and agrees with the
1911        // encoding for every remainder class.
1912        for raw in 0..=8usize {
1913            assert_eq!(
1914                encoded_image_len(raw),
1915                encode_image(&vec![0u8; raw]).len(),
1916                "raw length {raw}"
1917            );
1918        }
1919        assert_eq!(encoded_image_len(0), 0);
1920        assert_eq!(encoded_image_len(1), 4);
1921        assert_eq!(encoded_image_len(3), 4);
1922        assert_eq!(encoded_image_len(4), 8);
1923    }
1924
1925    #[test]
1926    fn a_parts_turn_carries_its_parts_in_order_and_a_text_turn_carries_none() {
1927        let with_image = Prompt::new(ModelTier::Fast).user_parts([
1928            Part::text("look"),
1929            Part::image(ImageMediaType::Jpeg, vec![1, 2, 3]),
1930        ]);
1931        let turn = &with_image.messages[0];
1932        assert_eq!(turn.role, Role::User);
1933        assert_eq!(turn.content, "", "the parts are the whole content");
1934        assert_eq!(
1935            turn.parts,
1936            vec![
1937                Part::Text("look".to_owned()),
1938                Part::Image {
1939                    media_type: ImageMediaType::Jpeg,
1940                    bytes: vec![1, 2, 3],
1941                },
1942            ]
1943        );
1944        assert!(with_image.has_images());
1945        assert!(
1946            !Prompt::new(ModelTier::Fast)
1947                .user_parts([Part::text("no image here")])
1948                .has_images()
1949        );
1950
1951        // The compatibility promise: a turn built the old way carries no
1952        // parts, so an adapter serialises it exactly as before images.
1953        let text_only = Prompt::new(ModelTier::Fast).user("hi");
1954        assert!(text_only.messages[0].parts.is_empty());
1955        assert!(!text_only.has_images());
1956        assert_eq!(text_only.check_images(), Ok(()));
1957    }
1958
1959    #[test]
1960    fn check_images_holds_each_bound_as_a_ceiling() {
1961        // At the bound the prompt passes; one step over, the matching error.
1962        let four = Prompt::new(ModelTier::Fast).user_parts(
1963            (0..MAX_PROMPT_IMAGES).map(|_| Part::image(ImageMediaType::Png, vec![0u8])),
1964        );
1965        assert_eq!(four.check_images(), Ok(()));
1966        let each_raw = raw_len_for_encoded(MAX_IMAGE_ENCODED_BYTES);
1967        assert_eq!(encoded_image_len(each_raw), MAX_IMAGE_ENCODED_BYTES);
1968        let at_ceiling = Prompt::new(ModelTier::Fast)
1969            .user_parts([Part::image(ImageMediaType::Png, vec![0u8; each_raw])]);
1970        assert_eq!(at_ceiling.check_images(), Ok(()));
1971
1972        // One image too many.
1973        let five = Prompt::new(ModelTier::Fast).user_parts(
1974            (0..=MAX_PROMPT_IMAGES).map(|_| Part::image(ImageMediaType::Png, vec![0u8])),
1975        );
1976        assert_eq!(
1977            five.check_images(),
1978            Err(TextModelError::ImageLimit(ImageLimit::TooMany {
1979                count: MAX_PROMPT_IMAGES + 1,
1980            }))
1981        );
1982
1983        // A raw length encoding to one base64 group over the per-image cap.
1984        let over = MAX_IMAGE_ENCODED_BYTES + 4;
1985        let over_raw = raw_len_for_encoded(over);
1986        assert_eq!(encoded_image_len(over_raw), over);
1987        let too_large = Prompt::new(ModelTier::Fast)
1988            .user_parts([Part::image(ImageMediaType::Png, vec![0u8; over_raw])]);
1989        assert_eq!(
1990            too_large.check_images(),
1991            Err(TextModelError::ImageLimit(ImageLimit::ImageTooLarge {
1992                index: 0,
1993                encoded_bytes: over,
1994            }))
1995        );
1996
1997        // Two images just under the per-image cap, together over the total:
1998        // the count and each image are legal, the sum is not.
1999        let each = MAX_IMAGE_ENCODED_BYTES - 4;
2000        let raw = raw_len_for_encoded(each);
2001        assert!(2 * each > MAX_PROMPT_IMAGE_ENCODED_BYTES);
2002        let too_total = Prompt::new(ModelTier::Fast).user_parts([
2003            Part::image(ImageMediaType::Png, vec![0u8; raw]),
2004            Part::image(ImageMediaType::Jpeg, vec![0u8; raw]),
2005        ]);
2006        assert_eq!(
2007            too_total.check_images(),
2008            Err(TextModelError::ImageLimit(ImageLimit::TotalTooLarge {
2009                encoded_bytes: 2 * each,
2010            }))
2011        );
2012    }
2013
2014    #[test]
2015    fn the_image_limit_error_is_displayed_without_any_user_content() {
2016        let error = TextModelError::ImageLimit(ImageLimit::TooMany { count: 9 });
2017        assert_eq!(
2018            error.to_string(),
2019            "the prompt's images exceed a limit: a prompt may carry at most 4 images, but this \
2020             one carries 9"
2021        );
2022        assert_eq!(error.retry_after(), None);
2023    }
2024
2025    #[test]
2026    fn images_to_a_tier_that_cannot_carry_them_are_refused_before_the_call() {
2027        let fast = Recording::new("fast-vendor");
2028        let router = RoutingTextModel::new().fast(fast.clone());
2029        let prompt = Prompt::new(ModelTier::Fast)
2030            .user_parts([Part::image(ImageMediaType::Png, vec![0u8; 4])]);
2031
2032        let error = pollster::block_on(router.complete(&prompt)).unwrap_err();
2033        assert_eq!(error, TextModelError::Unsupported(Capability::Images));
2034        assert_eq!(fast.count(), 0, "the adapter was never called");
2035
2036        // The capable mirror: the same prompt passes through untouched, but
2037        // an over-limit one is still refused by the router before the tier,
2038        // whatever the deployment claims.
2039        let capable = Recording::image_capable("fast-vendor");
2040        let router = RoutingTextModel::new().fast(capable.clone());
2041        let prompt = Prompt::new(ModelTier::Fast)
2042            .user_parts([Part::image(ImageMediaType::Webp, vec![0u8; 4])]);
2043        assert_eq!(
2044            pollster::block_on(router.complete(&prompt)).unwrap().model,
2045            "fast-vendor"
2046        );
2047        assert_eq!(capable.count(), 1);
2048
2049        let over = Prompt::new(ModelTier::Fast).user_parts(
2050            (0..=MAX_PROMPT_IMAGES).map(|_| Part::image(ImageMediaType::Png, vec![0u8])),
2051        );
2052        assert_eq!(
2053            pollster::block_on(router.complete(&over)).unwrap_err(),
2054            TextModelError::ImageLimit(ImageLimit::TooMany {
2055                count: MAX_PROMPT_IMAGES + 1,
2056            })
2057        );
2058        assert_eq!(capable.count(), 1, "the over-limit prompt never reached it");
2059    }
2060
2061    // -----------------------------------------------------------------
2062    // Streaming (issue #859)
2063
2064    /// A model whose native stream is deliberately not its `complete`
2065    /// decomposed: `complete` answers "buffered", `stream` yields
2066    /// "native" — so a forwarded stream is observable as forwarding, and
2067    /// a flattened one as the default's round trip.
2068    struct Native {
2069        seen: AtomicUsize,
2070        /// Incremented whenever a handed-out stream is dropped: the
2071        /// cancellation counter.
2072        cancelled: Arc<AtomicUsize>,
2073    }
2074
2075    impl Native {
2076        fn new() -> Arc<Self> {
2077            Arc::new(Self {
2078                seen: AtomicUsize::new(0),
2079                cancelled: Arc::new(AtomicUsize::new(0)),
2080            })
2081        }
2082
2083        fn completions(&self) -> usize {
2084            self.seen.load(Ordering::Relaxed)
2085        }
2086
2087        fn cancellations(&self) -> usize {
2088            self.cancelled.load(Ordering::Relaxed)
2089        }
2090    }
2091
2092    /// Owned by every stream [`Native`] hands out; its drop is the
2093    /// cancellation signal a route's disconnect relies on. A guard whose
2094    /// stream ran to its natural end is disarmed first: cleanup is not
2095    /// cancellation.
2096    struct DropGuard {
2097        counter: Arc<AtomicUsize>,
2098        armed: bool,
2099    }
2100
2101    impl DropGuard {
2102        fn new(counter: Arc<AtomicUsize>) -> Self {
2103            Self {
2104                counter,
2105                armed: true,
2106            }
2107        }
2108    }
2109
2110    impl Drop for DropGuard {
2111        fn drop(&mut self) {
2112            if self.armed {
2113                self.counter.fetch_add(1, Ordering::Relaxed);
2114            }
2115        }
2116    }
2117
2118    /// [`Native`]'s body: two deltas, then a clean end, holding its guard
2119    /// the whole way.
2120    struct NativeStream {
2121        guard: Option<DropGuard>,
2122        items: u8,
2123        /// Set once the stream has been polled to its natural end: a drop
2124        /// afterwards is cleanup, not cancellation.
2125        exhausted: bool,
2126    }
2127
2128    impl Stream for NativeStream {
2129        type Item = Result<TextDelta, TextModelError>;
2130
2131        fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
2132            // The guard is never handed over; it is held until the stream
2133            // itself drops.
2134            let _ = &self.guard;
2135            self.items += 1;
2136            match self.items {
2137                1 => Poll::Ready(Some(Ok(TextDelta::Text("native".to_owned())))),
2138                2 => Poll::Ready(Some(Ok(TextDelta::Finish {
2139                    reason: FinishReason::Stop,
2140                    model: "native-vendor".to_owned(),
2141                }))),
2142                3 => {
2143                    // A stand-in for a real upstream wait: park (waking the
2144                    // executor at once, so a full drain still ends). With an
2145                    // eager producer this is what lets a consumer's drop land
2146                    // mid-stream instead of racing the natural end.
2147                    cx.waker().wake_by_ref();
2148                    Poll::Pending
2149                }
2150                _ => {
2151                    self.exhausted = true;
2152                    Poll::Ready(None)
2153                }
2154            }
2155        }
2156    }
2157
2158    impl Drop for NativeStream {
2159        fn drop(&mut self) {
2160            // Dropping mid-stream is the cancellation a route's disconnect
2161            // relies on; dropping after the end is just cleanup, and must
2162            // not count.
2163            if self.exhausted
2164                && let Some(guard) = self.guard.as_mut()
2165            {
2166                guard.armed = false;
2167            }
2168        }
2169    }
2170
2171    #[async_trait]
2172    impl TextModel for Native {
2173        async fn complete(&self, _prompt: &Prompt) -> Result<Completion, TextModelError> {
2174            self.seen.fetch_add(1, Ordering::Relaxed);
2175            Ok(Completion::new("buffered", "native-vendor"))
2176        }
2177
2178        fn stream<'a>(
2179            &'a self,
2180            _prompt: &'a Prompt,
2181        ) -> BoxStream<'a, Result<TextDelta, TextModelError>> {
2182            Box::pin(NativeStream {
2183                guard: Some(DropGuard::new(Arc::clone(&self.cancelled))),
2184                items: 0,
2185                exhausted: false,
2186            })
2187        }
2188    }
2189
2190    #[test]
2191    fn completion_deltas_decomposes_text_tools_usage_and_finish() {
2192        let call = ToolCall::new("call-1", "lookup", serde_json::json!({ "q": "x" }));
2193        let completion = Completion::new("hello", "vendor-1")
2194            .usage(3, 5)
2195            .cached_input_tokens(2)
2196            .tool_calls(vec![call.clone()]);
2197        assert_eq!(
2198            completion_deltas(&completion),
2199            vec![
2200                TextDelta::Text("hello".to_owned()),
2201                TextDelta::ToolCallStarted {
2202                    index: 0,
2203                    id: "call-1".to_owned(),
2204                    name: "lookup".to_owned(),
2205                },
2206                TextDelta::ToolCallArguments {
2207                    index: 0,
2208                    chunk: r#"{"q":"x"}"#.to_owned(),
2209                },
2210                TextDelta::ToolCallFinished { index: 0, call },
2211                TextDelta::Usage {
2212                    input_tokens: 3,
2213                    output_tokens: 5,
2214                    cached_input_tokens: Some(2),
2215                },
2216                TextDelta::Finish {
2217                    reason: FinishReason::ToolUse,
2218                    model: "vendor-1".to_owned(),
2219                },
2220            ]
2221        );
2222    }
2223
2224    #[test]
2225    fn a_text_only_completion_finishes_stop_and_an_empty_one_carries_no_text_delta() {
2226        let completion = Completion::new("", "vendor-1");
2227        let deltas = completion_deltas(&completion);
2228        assert_eq!(deltas.len(), 2, "usage and finish only: {deltas:?}");
2229        assert!(matches!(
2230            deltas[1],
2231            TextDelta::Finish {
2232                reason: FinishReason::Stop,
2233                ..
2234            }
2235        ));
2236    }
2237
2238    #[pollster::test]
2239    async fn the_default_stream_is_one_round_trip_decomposed() {
2240        let model = Recording::new("fast-vendor");
2241        let prompt = Prompt::new(ModelTier::Fast).user("hi");
2242        let deltas: Vec<_> = model.stream(&prompt).collect().await;
2243        let completion = model.complete(&prompt).await.unwrap();
2244        assert_eq!(
2245            deltas,
2246            completion_deltas(&completion)
2247                .into_iter()
2248                .map(Ok)
2249                .collect::<Vec<_>>(),
2250            "the default stream is the completion, as deltas"
2251        );
2252        assert_eq!(model.count(), 2, "the stream made its one round trip");
2253    }
2254
2255    #[pollster::test]
2256    async fn a_builder_round_trips_deltas_back_to_the_completion_minus_json() {
2257        let call = ToolCall::new("call-1", "lookup", serde_json::json!({ "q": "x" }));
2258        let completion = Completion::new("the answer", "vendor-1")
2259            .json(serde_json::json!({ "reply": "the answer" }))
2260            .usage(12, 34)
2261            .cached_input_tokens(9)
2262            .tool_calls(vec![call.clone()]);
2263        let mut builder = CompletionBuilder::default();
2264        for delta in completion_deltas(&completion) {
2265            builder.push(&delta);
2266        }
2267        let rebuilt = builder.finish();
2268        let expected = Completion::new("the answer", "vendor-1")
2269            .usage(12, 34)
2270            .cached_input_tokens(9)
2271            .tool_calls(vec![call]);
2272        assert_eq!(rebuilt, expected);
2273        assert_eq!(
2274            rebuilt.json, None,
2275            "structured output is a `complete` feature"
2276        );
2277    }
2278
2279    #[test]
2280    fn the_builder_takes_usage_last_and_needs_only_finished_calls() {
2281        let mut builder = CompletionBuilder::default();
2282        builder.push(&TextDelta::Text("a".to_owned()));
2283        builder.push(&TextDelta::Reasoning(
2284            "never reaches a completion".to_owned(),
2285        ));
2286        builder.push(&TextDelta::ToolCallArguments {
2287            index: 0,
2288            chunk: "{\"x\":".to_owned(),
2289        });
2290        builder.push(&TextDelta::Usage {
2291            input_tokens: 1,
2292            output_tokens: 1,
2293            cached_input_tokens: None,
2294        });
2295        builder.push(&TextDelta::Usage {
2296            input_tokens: 7,
2297            output_tokens: 9,
2298            cached_input_tokens: Some(4),
2299        });
2300        builder.push(&TextDelta::Finish {
2301            reason: FinishReason::Length,
2302            model: "vendor-9".to_owned(),
2303        });
2304        assert_eq!(builder.finish_reason(), Some(&FinishReason::Length));
2305        let completion = builder.finish();
2306        assert_eq!(completion.text, "a");
2307        assert!(
2308            completion.tool_calls.is_empty(),
2309            "an unfinished call is not a call the tool loop may run"
2310        );
2311        assert_eq!(completion.input_tokens, 7);
2312        assert_eq!(completion.output_tokens, 9);
2313        assert_eq!(completion.cached_input_tokens, Some(4));
2314        assert_eq!(completion.model, "vendor-9");
2315    }
2316
2317    #[test]
2318    fn a_delta_serialises_with_a_type_tag_and_round_trips() {
2319        let deltas = vec![
2320            TextDelta::Text("hi".to_owned()),
2321            TextDelta::Reasoning("hmm".to_owned()),
2322            TextDelta::ToolCallStarted {
2323                index: 0,
2324                id: "c1".to_owned(),
2325                name: "lookup".to_owned(),
2326            },
2327            TextDelta::ToolCallArguments {
2328                index: 0,
2329                chunk: "{}".to_owned(),
2330            },
2331            TextDelta::ToolCallFinished {
2332                index: 0,
2333                call: ToolCall::new("c1", "lookup", serde_json::json!({})),
2334            },
2335            TextDelta::Usage {
2336                input_tokens: 1,
2337                output_tokens: 2,
2338                cached_input_tokens: None,
2339            },
2340            TextDelta::Finish {
2341                reason: FinishReason::ToolUse,
2342                model: "vendor-1".to_owned(),
2343            },
2344        ];
2345        for delta in &deltas {
2346            let json = serde_json::to_value(delta).expect("serialises");
2347            assert!(json.get("type").is_some(), "tagged: {json}");
2348            let back: TextDelta = serde_json::from_value(json).expect("deserialises");
2349            assert_eq!(&back, delta);
2350        }
2351        // The wire form a route writes is readable on its face.
2352        assert_eq!(
2353            serde_json::to_value(TextDelta::Text("hi".to_owned())).unwrap(),
2354            serde_json::json!({ "type": "text", "data": "hi" })
2355        );
2356    }
2357
2358    #[pollster::test]
2359    async fn the_router_forwards_stream_to_the_routed_tier() {
2360        let native = Native::new();
2361        let router = RoutingTextModel::new().fast(Arc::clone(&native) as Arc<dyn TextModel>);
2362        let prompt = Prompt::new(ModelTier::Fast).user("hi");
2363        let deltas: Vec<_> = router.stream(&prompt).collect().await;
2364        assert_eq!(
2365            deltas,
2366            vec![
2367                Ok(TextDelta::Text("native".to_owned())),
2368                Ok(TextDelta::Finish {
2369                    reason: FinishReason::Stop,
2370                    model: "native-vendor".to_owned(),
2371                }),
2372            ],
2373            "the inner model's native stream, not a flattened complete"
2374        );
2375        assert_eq!(
2376            native.completions(),
2377            0,
2378            "forwarding never fell back to the buffered round trip"
2379        );
2380    }
2381
2382    #[pollster::test]
2383    async fn the_router_refuses_a_stream_like_it_refuses_a_complete() {
2384        // Unwired tier: the router's own NotConfigured, as the first item.
2385        let native = Native::new();
2386        let router = RoutingTextModel::new().fast(Arc::clone(&native) as Arc<dyn TextModel>);
2387        let deltas: Vec<_> = router
2388            .stream(&Prompt::new(ModelTier::Strong).user("hi"))
2389            .collect()
2390            .await;
2391        assert_eq!(deltas, vec![Err(TextModelError::NotConfigured)]);
2392
2393        // Tools to a tier that cannot carry them: refused before the tier
2394        // is ever called, exactly as `complete` refuses.
2395        let incapable = Recording::new("plain");
2396        let router = RoutingTextModel::new().fast(Arc::clone(&incapable) as Arc<dyn TextModel>);
2397        let prompt = Prompt::new(ModelTier::Fast).user("hi").tool(ToolSpec::new(
2398            "lookup",
2399            "Look up.",
2400            serde_json::json!({}),
2401        ));
2402        let deltas: Vec<_> = router.stream(&prompt).collect().await;
2403        assert_eq!(
2404            deltas,
2405            vec![Err(TextModelError::Unsupported(Capability::Tools))]
2406        );
2407        assert_eq!(incapable.count(), 0, "the adapter was never called");
2408    }
2409
2410    #[pollster::test]
2411    async fn dropping_a_forwarded_stream_cancels_the_inner() {
2412        let native = Native::new();
2413        let router = RoutingTextModel::new().fast(Arc::clone(&native) as Arc<dyn TextModel>);
2414        {
2415            let prompt = Prompt::new(ModelTier::Fast).user("hi");
2416            let mut stream = router.stream(&prompt);
2417            let first = stream.next().await;
2418            assert_eq!(first, Some(Ok(TextDelta::Text("native".to_owned()))));
2419            // Dropped here, mid-stream.
2420        }
2421        assert_eq!(
2422            native.cancellations(),
2423            1,
2424            "the drop reached the inner stream"
2425        );
2426    }
2427
2428    #[pollster::test]
2429    async fn stream_owned_yields_a_static_stream_of_the_models_deltas() {
2430        let native = Native::new();
2431        // The `'static` bound is the point — this is the body a
2432        // `ResponseStream` needs — so assert it by naming it.
2433        let stream: BoxStream<'static, Result<TextDelta, TextModelError>> =
2434            stream_owned(native.clone(), Prompt::new(ModelTier::Fast).user("hi"));
2435        let deltas: Vec<_> = stream.collect().await;
2436        assert_eq!(
2437            deltas,
2438            vec![
2439                Ok(TextDelta::Text("native".to_owned())),
2440                Ok(TextDelta::Finish {
2441                    reason: FinishReason::Stop,
2442                    model: "native-vendor".to_owned(),
2443                }),
2444            ]
2445        );
2446        assert_eq!(native.completions(), 0);
2447        assert_eq!(
2448            native.cancellations(),
2449            0,
2450            "a drained stream is not a cancelled one"
2451        );
2452    }
2453
2454    #[pollster::test]
2455    async fn dropping_a_stream_owned_stream_cancels_the_model() {
2456        let native = Native::new();
2457        {
2458            let mut stream = stream_owned(native.clone(), Prompt::new(ModelTier::Fast).user("hi"));
2459            let first = stream.next().await;
2460            assert_eq!(first, Some(Ok(TextDelta::Text("native".to_owned()))));
2461            // Dropped here, before the model's stream ended.
2462        }
2463        assert_eq!(
2464            native.cancellations(),
2465            1,
2466            "the pump dropped the inner stream"
2467        );
2468    }
2469}