Skip to main content

cratefield_core/ports/
text_model.rs

1//! The `TextModel` port (issue #429): a text completion asked for by
2//! **tier**, never by vendor. A module says [`ModelTier::Fast`] for its
3//! drafting and [`ModelTier::Strong`] for its judging; which provider
4//! answers each tier is the venture's wiring, decided once in `src/lib.rs`
5//! and invisible to the module (ADR 0002). [`RoutingTextModel`] is the seam,
6//! the way [`RoutingPush`](crate::RoutingPush) is for transports.
7//!
8//! **No tools, no streaming, no embeddings in v1.** A completion is one
9//! request and one buffered answer. Streaming is the deliberate omission,
10//! not a gap: `response_to_worker` buffers a whole harness response to
11//! `MAX_RESPONSE_BUFFER` (1 MiB, `crates/runtime-cloudflare/src/lib.rs`),
12//! so a streamed completion has nowhere to arrive on this runtime — a port
13//! that promised deltas would be a port the Workers twin could not keep.
14//! Tools and embeddings change the shape of the call and the answer, and
15//! nothing in the tree needs either yet.
16//!
17//! There is no outcome enum on this port, unlike [`Mailer`](crate::Mailer)
18//! and [`Push`](crate::Push), and that is deliberate: a completion has no
19//! "delivered but not configured" middle state — either text came back or
20//! nothing did. The unwired answer is therefore
21//! [`TextModelError::NotConfigured`], an error variant the caller can match,
22//! so a module that cannot degrade without its model fails loudly instead of
23//! silently producing nothing.
24
25use std::sync::Arc;
26use std::time::Duration;
27
28use async_trait::async_trait;
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32/// The token ceiling a [`Prompt`] starts with: enough for a drafted reply,
33/// small enough that a forgotten `.max_tokens(..)` cannot turn into a run
34/// away bill. Explicit in the type so "how long can the answer get" is a
35/// field a caller can read, not an adapter's private default.
36pub const DEFAULT_MAX_TOKENS: u32 = 1024;
37
38/// Which class of model a completion asks for — a **tier**, never a vendor
39/// or a model name. A venture maps each tier onto a provider in its own
40/// wiring, and can move drafting from one vendor to another without
41/// touching a module.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ModelTier {
45    /// The cheap, quick class: drafting, summarising, classifying.
46    Fast,
47    /// The best-available class: judging, long synthesis, the one call
48    /// where quality is the point.
49    Strong,
50}
51
52impl ModelTier {
53    /// The name used in errors and logs.
54    pub fn name(&self) -> &'static str {
55        match self {
56            ModelTier::Fast => "fast",
57            ModelTier::Strong => "strong",
58        }
59    }
60}
61
62impl std::fmt::Display for ModelTier {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(self.name())
65    }
66}
67
68/// Who is speaking in a [`Turn`]. A prompt is a conversation, and a
69/// provider needs to know which side each part came from.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Role {
73    /// The human (or module) side of the conversation.
74    User,
75    /// The model's side, as a previous completion was recorded.
76    Assistant,
77}
78
79impl Role {
80    /// The name used in errors and logs.
81    pub fn name(&self) -> &'static str {
82        match self {
83            Role::User => "user",
84            Role::Assistant => "assistant",
85        }
86    }
87}
88
89impl std::fmt::Display for Role {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(self.name())
92    }
93}
94
95/// One message of the conversation a [`Prompt`] carries.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Turn {
98    pub role: Role,
99    pub content: String,
100}
101
102impl Turn {
103    /// A turn spoken by the [`Role::User`] side.
104    #[must_use]
105    pub fn user(content: impl Into<String>) -> Self {
106        Turn {
107            role: Role::User,
108            content: content.into(),
109        }
110    }
111
112    /// A turn spoken by the [`Role::Assistant`] side — a previous
113    /// completion, quoted back as context.
114    #[must_use]
115    pub fn assistant(content: impl Into<String>) -> Self {
116        Turn {
117            role: Role::Assistant,
118            content: content.into(),
119        }
120    }
121}
122
123/// One completion request: the tier asked for, the conversation so far, and
124/// what the caller will accept back.
125///
126/// `#[non_exhaustive]`: build one with [`Prompt::new`] and the builder
127/// methods rather than a struct literal. What a v2 port has to carry —
128/// tools, temperature, a stop sequence — should not be a breaking change
129/// for every caller, the same way [`Message`](crate::Message) is built.
130#[derive(Debug, Clone, PartialEq)]
131#[non_exhaustive]
132pub struct Prompt {
133    pub tier: ModelTier,
134    pub system: Option<String>,
135    pub messages: Vec<Turn>,
136    /// A JSON Schema (draft 2020-12) the answer must conform to. When set,
137    /// the adapter asks its provider for structured output and a
138    /// successful [`Completion`] carries the parsed value in
139    /// [`Completion::json`].
140    pub json_schema: Option<Value>,
141    pub max_tokens: u32,
142}
143
144impl Prompt {
145    /// An empty prompt for `tier`: no system prompt, no messages, no
146    /// schema, and [`DEFAULT_MAX_TOKENS`] as the ceiling. Everything else
147    /// is a builder method.
148    #[must_use]
149    pub fn new(tier: ModelTier) -> Self {
150        Self {
151            tier,
152            system: None,
153            messages: Vec::new(),
154            json_schema: None,
155            max_tokens: DEFAULT_MAX_TOKENS,
156        }
157    }
158
159    /// The standing instruction the model answers under.
160    #[must_use]
161    pub fn system(mut self, system: impl Into<String>) -> Self {
162        self.system = Some(system.into());
163        self
164    }
165
166    /// Appends a [`Turn::user`] — the common case, a one-message prompt.
167    #[must_use]
168    pub fn user(mut self, content: impl Into<String>) -> Self {
169        self.messages.push(Turn::user(content));
170        self
171    }
172
173    /// Appends a [`Turn::assistant`].
174    #[must_use]
175    pub fn assistant(mut self, content: impl Into<String>) -> Self {
176        self.messages.push(Turn::assistant(content));
177        self
178    }
179
180    /// Appends a whole turn, for a conversation built elsewhere.
181    #[must_use]
182    pub fn turn(mut self, turn: Turn) -> Self {
183        self.messages.push(turn);
184        self
185    }
186
187    /// Asks for structured output conforming to `schema`; a successful
188    /// completion then carries the parsed value in [`Completion::json`].
189    #[must_use]
190    pub fn json_schema(mut self, schema: Value) -> Self {
191        self.json_schema = Some(schema);
192        self
193    }
194
195    /// The token ceiling for the answer.
196    #[must_use]
197    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
198        self.max_tokens = max_tokens;
199        self
200    }
201}
202
203/// A completed completion: the text, the model that answered (a provider
204/// identifier, for the log line), and the token usage.
205///
206/// The two token counts have a fixed relationship: [`Completion::input_tokens`]
207/// is the **total** prompt tokens the provider processed, and
208/// [`Completion::cached_input_tokens`] — where the provider reports one —
209/// is the subset of that total served from the provider's own prompt
210/// cache. A caller costing a completion never subtracts; the total already
211/// includes the cached part.
212///
213/// `#[non_exhaustive]` for the same reason [`Prompt`] is: what a provider
214/// reports back grows, and it should not break every caller.
215#[derive(Debug, Clone, PartialEq)]
216#[non_exhaustive]
217pub struct Completion {
218    pub text: String,
219    /// The parsed answer, when the prompt carried a
220    /// [`Prompt::json_schema`]. `None` when it did not, or the provider's
221    /// answer could not be parsed — in which case `text` still holds what
222    /// came back.
223    pub json: Option<Value>,
224    pub model: String,
225    /// The total prompt tokens the provider processed, cached tokens
226    /// included — a provider that reports caching reports them alongside
227    /// the uncached remainder, not inside it. The discount question is
228    /// [`Completion::cached_input_tokens`], never a smaller total here.
229    pub input_tokens: u64,
230    pub output_tokens: u64,
231    /// The subset of [`Completion::input_tokens`] the provider served from
232    /// its own prompt cache, where the provider reports one. `None` when it
233    /// does not: an absent report and a reported zero mean different things
234    /// (a vendor that does not do caching versus a cache miss), so the
235    /// option is never collapsed.
236    pub cached_input_tokens: Option<u64>,
237}
238
239impl Completion {
240    /// A completion with just the text and the model that wrote it; the
241    /// usage and parsed JSON are builder methods.
242    #[must_use]
243    pub fn new(text: impl Into<String>, model: impl Into<String>) -> Self {
244        Self {
245            text: text.into(),
246            json: None,
247            model: model.into(),
248            input_tokens: 0,
249            output_tokens: 0,
250            cached_input_tokens: None,
251        }
252    }
253
254    /// The parsed structured answer, for a prompt that asked for one.
255    #[must_use]
256    pub fn json(mut self, json: Value) -> Self {
257        self.json = Some(json);
258        self
259    }
260
261    /// The token counts the provider reported.
262    #[must_use]
263    pub fn usage(mut self, input_tokens: u64, output_tokens: u64) -> Self {
264        self.input_tokens = input_tokens;
265        self.output_tokens = output_tokens;
266        self
267    }
268
269    /// The subset of the input tokens the provider served from its own
270    /// prompt cache, where the provider reports one.
271    /// [`Completion::input_tokens`] stays the total, cached included.
272    #[must_use]
273    pub fn cached_input_tokens(mut self, cached_input_tokens: u64) -> Self {
274        self.cached_input_tokens = Some(cached_input_tokens);
275        self
276    }
277}
278
279/// Completion failures.
280///
281/// [`NotConfigured`](Self::NotConfigured) sits on the **error** enum here,
282/// unlike [`SendOutcome::NotConfigured`](crate::SendOutcome) and
283/// [`PushOutcome::NotConfigured`](crate::PushOutcome): a completion has no
284/// "delivered but not configured" middle state, so an unwired tier is an
285/// error the caller matches, not an outcome it inspects.
286///
287/// `Transient` deliberately carries **only** `retry_after`, where
288/// [`PushError::Transient`](crate::PushError) also carries a message: with
289/// no provider text of its own there is nothing to scrub, and provider text
290/// belongs on [`Rejected`](Self::Rejected) and [`Transport`](Self::Transport).
291///
292/// The two variants that carry provider text are sanitized in `Display`,
293/// the same way [`PushError`](crate::PushError)'s and
294/// [`MailError`](crate::MailError)'s are (issue #235). What an adapter wraps
295/// is the provider's own words, and a prompt is exactly the kind of value
296/// that rides back in them — a drafting module quotes a customer's note, and
297/// the provider's `4xx` quotes it straight back. `Display` therefore runs it
298/// through [`crate::logging::scrub_text`]; `Debug` still shows the raw
299/// string for tests.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub enum TextModelError {
302    /// The tier asked for has no adapter — the venture did not wire it.
303    /// Nothing is wrong with the prompt: a module may degrade, the way it
304    /// degrades on a `NotConfigured` mailer, and
305    /// [`RoutingTextModel`] answers this for every tier it has no
306    /// adapter for.
307    NotConfigured,
308    /// The provider refused the request (a `4xx`), or refused the prompt's
309    /// content or schema; not retryable without a change.
310    Rejected(String),
311    /// A transient failure (a `5xx`, a `429`, a transport error): retry
312    /// later, and not before `retry_after` when the provider named one.
313    /// Carries no message — provider text belongs on
314    /// [`Rejected`](Self::Rejected) and [`Transport`](Self::Transport).
315    Transient { retry_after: Option<Duration> },
316    /// The request never completed as a conversation — the adapter could
317    /// not reach the provider, or the answer did not survive the hop.
318    Transport(String),
319}
320
321impl std::fmt::Display for TextModelError {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        let scrub = crate::logging::scrub_text;
324        match self {
325            Self::NotConfigured => f.write_str("no text model is wired for this tier"),
326            Self::Rejected(message) => write!(f, "completion rejected: {}", scrub(message)),
327            Self::Transient { .. } => f.write_str("completion failed, retryable"),
328            Self::Transport(message) => {
329                write!(f, "completion transport failed: {}", scrub(message))
330            }
331        }
332    }
333}
334
335impl std::error::Error for TextModelError {}
336
337impl TextModelError {
338    /// How long the provider asked the caller to wait, where it said.
339    pub fn retry_after(&self) -> Option<Duration> {
340        match self {
341            TextModelError::Transient { retry_after } => *retry_after,
342            _ => None,
343        }
344    }
345}
346
347/// Completes a prompt, over whichever provider the venture wired for the
348/// [`Prompt::tier`] it was asked for. An adapter serves any tier it is
349/// configured for (typically one); [`RoutingTextModel`] dispatches between
350/// the adapters a venture configured.
351#[async_trait]
352pub trait TextModel: Send + Sync {
353    /// Completes `prompt`.
354    ///
355    /// [`Prompt::json_schema`] is a request: an adapter whose provider
356    /// cannot honour structured output answers with plain text in
357    /// [`Completion::text`] and `None` in [`Completion::json`], rather than
358    /// failing the call.
359    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError>;
360}
361
362/// Dispatches by [`Prompt::tier`] to the adapter a venture configured for
363/// that tier, so venture code holds one `Arc<dyn TextModel>` and never
364/// matches on the tier or the vendor itself (ADR 0002). The router lives
365/// above the adapters, in core — the same place
366/// [`RoutingPush`](crate::RoutingPush) does, and for the same reason
367/// (ADR 0015).
368///
369/// This is the seam that lets a venture put drafting on one vendor and an
370/// independent judge on another without either module knowing.
371///
372/// A tier with no adapter is [`TextModelError::NotConfigured`] —
373/// deliberately not [`TextModelError::Rejected`]: nothing is wrong with the
374/// prompt, the venture simply did not wire that tier.
375#[derive(Default, Clone)]
376pub struct RoutingTextModel {
377    fast: Option<Arc<dyn TextModel>>,
378    strong: Option<Arc<dyn TextModel>>,
379}
380
381impl RoutingTextModel {
382    /// A router with no adapters: every tier is `NotConfigured`.
383    #[must_use]
384    pub fn new() -> Self {
385        Self::default()
386    }
387
388    /// The adapter for [`ModelTier::Fast`].
389    #[must_use]
390    pub fn fast(mut self, model: Arc<dyn TextModel>) -> Self {
391        self.fast = Some(model);
392        self
393    }
394
395    /// The adapter for [`ModelTier::Strong`].
396    #[must_use]
397    pub fn strong(mut self, model: Arc<dyn TextModel>) -> Self {
398        self.strong = Some(model);
399        self
400    }
401
402    /// The adapter that serves `tier`, if one is configured.
403    #[must_use]
404    pub fn route_for(&self, tier: ModelTier) -> Option<&Arc<dyn TextModel>> {
405        match tier {
406            ModelTier::Fast => self.fast.as_ref(),
407            ModelTier::Strong => self.strong.as_ref(),
408        }
409    }
410}
411
412impl std::fmt::Debug for RoutingTextModel {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        f.debug_struct("RoutingTextModel")
415            .field("fast", &self.fast.is_some())
416            .field("strong", &self.strong.is_some())
417            .finish()
418    }
419}
420
421#[async_trait]
422impl TextModel for RoutingTextModel {
423    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
424        match self.route_for(prompt.tier) {
425            Some(model) => model.complete(prompt).await,
426            None => Err(TextModelError::NotConfigured),
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    // -----------------------------------------------------------------
436    // ModelTier, Role, and the wire form
437
438    #[test]
439    fn a_tier_and_a_role_round_trip_through_json() {
440        for tier in [ModelTier::Fast, ModelTier::Strong] {
441            let json = serde_json::to_string(&tier).expect("serialises");
442            let back: ModelTier = serde_json::from_str(&json).expect("deserialises");
443            assert_eq!(tier, back);
444        }
445        for role in [Role::User, Role::Assistant] {
446            let json = serde_json::to_string(&role).expect("serialises");
447            let back: Role = serde_json::from_str(&json).expect("deserialises");
448            assert_eq!(role, back);
449        }
450    }
451
452    #[test]
453    fn the_wire_form_is_snake_case() {
454        // The point of `rename_all`: the persisted name is the prose name,
455        // so a config file reads `"strong"`, not `"Strong"`.
456        assert_eq!(serde_json::to_value(ModelTier::Strong).unwrap(), "strong");
457        assert_eq!(serde_json::to_value(Role::User).unwrap(), "user");
458    }
459
460    #[test]
461    fn the_tier_names_itself_for_logs_and_errors() {
462        assert_eq!(ModelTier::Fast.name(), "fast");
463        assert_eq!(ModelTier::Strong.to_string(), "strong");
464        assert_eq!(Role::Assistant.name(), "assistant");
465        assert_eq!(Role::User.to_string(), "user");
466    }
467
468    // -----------------------------------------------------------------
469    // Prompt and Completion builders
470
471    #[test]
472    fn a_prompt_starts_empty_and_builds_up() {
473        let prompt = Prompt::new(ModelTier::Fast);
474        assert_eq!(prompt.tier, ModelTier::Fast);
475        assert_eq!(prompt.system, None);
476        assert!(prompt.messages.is_empty());
477        assert_eq!(prompt.json_schema, None);
478        assert_eq!(prompt.max_tokens, DEFAULT_MAX_TOKENS);
479        assert_eq!(DEFAULT_MAX_TOKENS, 1024);
480
481        let prompt = prompt
482            .system("Draft the reply.")
483            .user("Hello")
484            .assistant("Hi there")
485            .turn(Turn::user("And again"))
486            .json_schema(serde_json::json!({ "type": "object" }))
487            .max_tokens(256);
488
489        assert_eq!(prompt.system.as_deref(), Some("Draft the reply."));
490        assert_eq!(
491            prompt.messages,
492            vec![
493                Turn::user("Hello"),
494                Turn::assistant("Hi there"),
495                Turn::user("And again"),
496            ]
497        );
498        assert_eq!(
499            prompt.json_schema,
500            Some(serde_json::json!({ "type": "object" }))
501        );
502        assert_eq!(prompt.max_tokens, 256);
503    }
504
505    #[test]
506    fn a_completion_starts_with_text_and_model_and_builds_up() {
507        let completion = Completion::new("the answer", "vendor-1");
508        assert_eq!(completion.text, "the answer");
509        assert_eq!(completion.model, "vendor-1");
510        assert_eq!(completion.json, None);
511        assert_eq!(completion.input_tokens, 0);
512        assert_eq!(completion.output_tokens, 0);
513
514        let completion = completion
515            .json(serde_json::json!({ "reply": "the answer" }))
516            .usage(12, 34);
517        assert_eq!(
518            completion.json,
519            Some(serde_json::json!({ "reply": "the answer" }))
520        );
521        assert_eq!(completion.input_tokens, 12);
522        assert_eq!(completion.output_tokens, 34);
523    }
524
525    #[test]
526    fn cached_input_tokens_defaults_to_absent_and_never_shrinks_the_total() {
527        // A vendor that says nothing about caching stays `None` — a
528        // reported zero (a cache miss) and an absent report (a vendor
529        // without caching) are different facts.
530        let completion = Completion::new("the answer", "vendor-1");
531        assert_eq!(completion.cached_input_tokens, None);
532
533        // The builder records the cache read without shrinking the total:
534        // `input_tokens` is what the vendor processed, cached included.
535        let completion = Completion::new("the answer", "vendor-1")
536            .usage(17, 5)
537            .cached_input_tokens(9);
538        assert_eq!(completion.input_tokens, 17);
539        assert_eq!(completion.cached_input_tokens, Some(9));
540    }
541
542    // -----------------------------------------------------------------
543    // TextModelError
544
545    #[test]
546    fn display_sanitizes_the_provider_text() {
547        // Issue #235. The text an adapter wraps is the provider's own
548        // words, and a drafting prompt quotes whatever a user wrote: what
549        // the provider echoes back must not survive into a log line or a
550        // dead-letter row carrying its URLs, tokens or addresses.
551        let error = TextModelError::Rejected(
552            "provider 400 for https://api.example.test/v1/complete?token=secret-abcdef".to_owned(),
553        );
554        let text = error.to_string();
555        assert!(text.contains("completion rejected"), "{text}");
556        assert!(!text.contains("secret-abcdef"), "{text}");
557        assert!(text.contains("?[redacted]"), "{text}");
558
559        let error = TextModelError::Transport("timeout quoting alice@example.test".to_owned());
560        let text = error.to_string();
561        assert!(text.contains("completion transport failed"), "{text}");
562        assert!(!text.contains('@'), "{text}");
563
564        // `Debug` still shows the raw string for a failing test to read.
565        assert!(format!("{error:?}").contains("alice@example.test"));
566    }
567
568    #[test]
569    fn transient_says_retryable_and_carries_no_text_to_scrub() {
570        // `Transient` has no message field on purpose — the fixed sentence
571        // is the whole `Display`, and a provider's words would be an
572        // unsanitised leak by construction.
573        let error = TextModelError::Transient {
574            retry_after: Some(Duration::from_secs(30)),
575        };
576        assert_eq!(error.to_string(), "completion failed, retryable");
577    }
578
579    #[test]
580    fn transient_carries_an_optional_retry_after() {
581        let throttled = TextModelError::Transient {
582            retry_after: Some(Duration::from_secs(30)),
583        };
584        assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
585        let plain = TextModelError::Transient { retry_after: None };
586        assert_eq!(plain.retry_after(), None);
587        assert_eq!(TextModelError::NotConfigured.retry_after(), None);
588        assert_eq!(
589            TextModelError::Rejected("422".to_owned()).retry_after(),
590            None,
591            "a rejection is not a back-off"
592        );
593        assert_eq!(
594            TextModelError::Transport("timed out".to_owned()).retry_after(),
595            None
596        );
597    }
598
599    #[test]
600    fn not_configured_names_the_missing_wiring() {
601        assert_eq!(
602            TextModelError::NotConfigured.to_string(),
603            "no text model is wired for this tier"
604        );
605    }
606
607    // -----------------------------------------------------------------
608    // RoutingTextModel
609
610    struct Recording {
611        label: &'static str,
612        seen: std::sync::atomic::AtomicUsize,
613    }
614
615    impl Recording {
616        fn new(label: &'static str) -> Arc<Self> {
617            Arc::new(Self {
618                label,
619                seen: std::sync::atomic::AtomicUsize::new(0),
620            })
621        }
622        fn count(&self) -> usize {
623            self.seen.load(std::sync::atomic::Ordering::Relaxed)
624        }
625    }
626
627    #[async_trait]
628    impl TextModel for Recording {
629        async fn complete(&self, _prompt: &Prompt) -> Result<Completion, TextModelError> {
630            self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
631            Ok(Completion::new("recorded", self.label))
632        }
633    }
634
635    #[test]
636    fn the_router_dispatches_by_tier() {
637        let fast = Recording::new("fast-vendor");
638        let strong = Recording::new("strong-vendor");
639        let router = RoutingTextModel::new()
640            .fast(fast.clone())
641            .strong(strong.clone());
642
643        let completion =
644            pollster::block_on(router.complete(&Prompt::new(ModelTier::Fast).user("hi"))).unwrap();
645        assert_eq!(
646            completion.model, "fast-vendor",
647            "the fast tier reached its adapter"
648        );
649        let completion =
650            pollster::block_on(router.complete(&Prompt::new(ModelTier::Strong).user("hi")))
651                .unwrap();
652        assert_eq!(
653            completion.model, "strong-vendor",
654            "the strong tier reached its own, different adapter"
655        );
656        assert_eq!(fast.count(), 1);
657        assert_eq!(strong.count(), 1);
658    }
659
660    #[test]
661    fn a_tier_with_no_adapter_is_not_configured_not_rejected() {
662        let router = RoutingTextModel::new().fast(Recording::new("fast-vendor"));
663        let error = pollster::block_on(router.complete(&Prompt::new(ModelTier::Strong).user("hi")))
664            .unwrap_err();
665        assert_eq!(error, TextModelError::NotConfigured);
666        assert!(router.route_for(ModelTier::Strong).is_none());
667    }
668
669    #[test]
670    fn an_empty_router_is_not_configured_for_every_tier() {
671        let router = RoutingTextModel::new();
672        for tier in [ModelTier::Fast, ModelTier::Strong] {
673            let error =
674                pollster::block_on(router.complete(&Prompt::new(tier).user("hi"))).unwrap_err();
675            assert_eq!(error, TextModelError::NotConfigured);
676            assert!(router.route_for(tier).is_none());
677        }
678    }
679
680    #[test]
681    fn debug_prints_which_tiers_are_wired_and_nothing_else() {
682        // The adapters themselves are `Arc<dyn TextModel>` and have no
683        // meaningful `Debug`; printing their presence is the whole report.
684        let router = RoutingTextModel::new().fast(Recording::new("fast-vendor"));
685        let printed = format!("{router:?}");
686        assert!(printed.contains("RoutingTextModel"), "{printed}");
687        assert!(printed.contains("fast: true"), "{printed}");
688        assert!(printed.contains("strong: false"), "{printed}");
689    }
690}