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}