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}