Skip to main content

codewhale_workflow/
reasoning_router.rs

1//! The **Adaptive Reasoning Router** — a saved, reusable *service*, not a Fleet
2//! member.
3//!
4//! A Fleet answers *who*: the exact provider/model assignments an operator
5//! saved for each worker. A Reasoning Router answers a much smaller question,
6//! for an already frozen worker route: *how hard should this already-chosen
7//! model think on this task?* Those are different kinds of thing, so they are
8//! different kinds of value here:
9//!
10//! - A Router is **never dispatchable**. It has no role, no tools, no shell, no
11//!   write authority, and no delegation budget. It cannot be named by a task.
12//! - A Router is **referenced, not embedded**. It is saved once at
13//!   `routers/<name>.toml` and referenced by name from any number of Fleets, so
14//!   two Fleets can share one Router configuration without duplicating it.
15//! - A Router **never changes a route**. Provider, model, member, role, tools,
16//!   and permissions are all frozen before it is called and are not among the
17//!   things it is allowed to answer.
18//!
19//! ## Cheap by construction
20//!
21//! A Router call is a per-task tax on someone's tokens, so its own reasoning is
22//! capped at [`RouterCallReasoning`] — `off` or `low`, nothing else. `medium`,
23//! `high`, and `max` are **rejected at parse time rather than silently clamped**:
24//! an operator who wrote `high` asked for something this service will not do,
25//! and quietly running at `off` while the file says `high` is exactly the kind
26//! of invisible substitution receipts exist to prevent.
27//!
28//! The reverse lie is equally forbidden. A profile that asks for `low` is
29//! *called* at `low` wherever the route can express it; nothing here forces
30//! `off` and then reports `low`. Normalization against the route's real
31//! capability is recorded on the receipt (see
32//! [`crate::fleet_reasoning::RouterCallDisclosure`]).
33//!
34//! ## Legacy inline routers
35//!
36//! The prototype form — a `[[members]]` entry with `kind = "router"` inside the
37//! Fleet file — still parses, is labelled `legacy_inline`, and is **normalized
38//! into the same [`CapturedReasoningRouter`]** the named store produces. There
39//! is one runtime representation of the service, whichever way it was written.
40
41use std::path::PathBuf;
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::fleet_exact::{FrozenRoute, PermissionCeiling, ReasoningTier, RouterMember};
47use crate::named_fleet::FleetSearchRoot;
48
49/// Directory (under each search root) that holds saved Router profiles.
50pub const REASONING_ROUTER_DIR: &str = "routers";
51/// Wire value of the `schema` key that selects a Router profile document.
52pub const REASONING_ROUTER_SCHEMA_KIND: &str = "reasoning_router";
53/// Current revision of the Router profile schema.
54pub const REASONING_ROUTER_SCHEMA_REVISION: u32 = 1;
55/// Stable service label a receipt prints so the reader can tell at a glance
56/// that this is the reasoning service and not a Fleet member.
57pub const REASONING_ROUTER_SERVICE_KIND: &str = "reasoning_router";
58/// Origin recorded for a Router that was written inline in a Fleet file.
59pub const LEGACY_INLINE_ROUTER_ORIGIN: &str = "legacy_inline";
60
61/// The reasoning a **Router call itself** may run at.
62///
63/// Deliberately not [`ReasoningTier`]: this type exists precisely so that
64/// `medium`/`high`/`max` are unrepresentable. A Router emits one ~15-byte JSON
65/// object; anything above `low` spends a user's tokens on thinking about a
66/// question that does not need thought.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum RouterCallReasoning {
70    #[default]
71    Off,
72    Low,
73}
74
75impl RouterCallReasoning {
76    #[must_use]
77    pub const fn as_str(self) -> &'static str {
78        match self {
79            Self::Off => "off",
80            Self::Low => "low",
81        }
82    }
83
84    /// The concrete tier this maps onto for capability normalization.
85    #[must_use]
86    pub const fn tier(self) -> ReasoningTier {
87        match self {
88            Self::Off => ReasoningTier::Off,
89            Self::Low => ReasoningTier::Low,
90        }
91    }
92
93    /// Parse a configured value.
94    ///
95    /// `medium`/`high`/`max` are a distinct, named error rather than a clamp —
96    /// see the module docs.
97    pub fn parse(value: &str, router: &str) -> Result<Self, ReasoningRouterError> {
98        let trimmed = value.trim();
99        match trimmed.to_ascii_lowercase().as_str() {
100            "off" | "none" | "disabled" => Ok(Self::Off),
101            "low" | "minimal" => Ok(Self::Low),
102            "medium" | "mid" | "high" | "max" | "maximum" | "xhigh" => {
103                Err(ReasoningRouterError::CallReasoningTooExpensive {
104                    router: router.to_string(),
105                    value: trimmed.to_string(),
106                })
107            }
108            _ => Err(ReasoningRouterError::InvalidCallReasoning {
109                router: router.to_string(),
110                value: trimmed.to_string(),
111            }),
112        }
113    }
114}
115
116/// A Router identity qualified by the origin its definition came from.
117///
118/// Path-free for the same reason [`crate::QualifiedFleetId`] is: an absolute
119/// path in a durable receipt leaks the operator's home directory.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct QualifiedRouterId {
122    pub name: String,
123    pub origin: String,
124}
125
126impl QualifiedRouterId {
127    #[must_use]
128    pub fn qualified(&self) -> String {
129        format!("{}/{}", self.origin, self.name)
130    }
131}
132
133/// A saved Router profile: one exact provider/model plus a cheap call ceiling.
134///
135/// This is *the* reusable unit. Any number of Fleets may reference the same
136/// profile by name; none of them owns it.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ReasoningRouterProfile {
139    pub name: String,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub description: Option<String>,
142    pub schema_revision: u32,
143    /// Exact configured provider id.
144    pub provider: String,
145    /// Exact model id.
146    pub model: String,
147    /// What the Router's own call runs at. `off` or `low` only.
148    pub call_reasoning: RouterCallReasoning,
149}
150
151impl ReasoningRouterProfile {
152    /// Parse a Router profile document.
153    pub fn parse(text: &str) -> Result<Self, ReasoningRouterError> {
154        let doc: RouterProfileToml =
155            toml::from_str(text).map_err(|error| ReasoningRouterError::Parse(error.to_string()))?;
156        if !doc
157            .schema
158            .trim()
159            .eq_ignore_ascii_case(REASONING_ROUTER_SCHEMA_KIND)
160        {
161            return Err(ReasoningRouterError::UnknownSchema {
162                schema: doc.schema.trim().to_string(),
163            });
164        }
165        if doc.schema_revision != REASONING_ROUTER_SCHEMA_REVISION {
166            return Err(ReasoningRouterError::UnsupportedRevision {
167                revision: doc.schema_revision,
168                supported: REASONING_ROUTER_SCHEMA_REVISION,
169            });
170        }
171        let name = crate::role_resolve::normalize_token(&doc.name).ok_or_else(|| {
172            ReasoningRouterError::InvalidToken {
173                field: "name".to_string(),
174                value: doc.name.trim().to_string(),
175            }
176        })?;
177        let provider = exact_token(&doc.provider, &name, "provider")?;
178        let model = exact_token(&doc.model, &name, "model")?;
179        let call_reasoning = match doc.call_reasoning.as_deref() {
180            None => RouterCallReasoning::default(),
181            Some(value) => RouterCallReasoning::parse(value, &name)?,
182        };
183
184        Ok(Self {
185            name,
186            description: doc.description,
187            schema_revision: doc.schema_revision,
188            provider,
189            model,
190            call_reasoning,
191        })
192    }
193
194    /// Load one profile from a labelled search root set.
195    ///
196    /// A bare name present under more than one origin is **ambiguous**: a
197    /// personal `~/.codewhale` Router silently shadowing a project Router would
198    /// change which provider sees every task's routing summary. Naming the
199    /// origin (`codewhale_home/fast`) resolves it.
200    pub fn load_by_name(
201        name: &str,
202        search_roots: &[FleetSearchRoot],
203    ) -> Result<(Self, QualifiedRouterId), ReasoningRouterError> {
204        let (requested_origin, bare) = split_qualified(name);
205        if bare.is_empty() {
206            return Err(ReasoningRouterError::InvalidToken {
207                field: "router reference".to_string(),
208                value: name.trim().to_string(),
209            });
210        }
211        let file_name = format!("{bare}.toml");
212
213        let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
214        for root in search_roots {
215            if let Some(origin) = requested_origin
216                && !root.origin.eq_ignore_ascii_case(origin)
217            {
218                continue;
219            }
220            let path = root.root.join(REASONING_ROUTER_DIR).join(&file_name);
221            if path.is_file() {
222                candidates.push((root, path));
223            }
224        }
225
226        let Some((first_root, first_path)) = candidates.first() else {
227            return Err(ReasoningRouterError::NotFound {
228                name: name.trim().to_string(),
229            });
230        };
231        if candidates.len() > 1 {
232            // Unlike legacy Fleet role maps, there is no first-hit-wins fallback
233            // here: every Router profile names an exact provider/model, so a
234            // shadowed one always changes behavior.
235            return Err(ReasoningRouterError::AmbiguousRouter {
236                name: bare.to_string(),
237                origins: candidates
238                    .iter()
239                    .map(|(root, _)| format!("{}/{bare}", root.origin))
240                    .collect(),
241            });
242        }
243
244        let text =
245            std::fs::read_to_string(first_path).map_err(|error| ReasoningRouterError::Io {
246                path: first_path.display().to_string(),
247                message: error.to_string(),
248            })?;
249        let profile = Self::parse(&text)?;
250        if profile.name != bare {
251            return Err(ReasoningRouterError::NameMismatch {
252                declared: profile.name.clone(),
253                expected: bare.to_string(),
254            });
255        }
256        let id = QualifiedRouterId {
257            name: profile.name.clone(),
258            origin: first_root.origin.clone(),
259        };
260        Ok((profile, id))
261    }
262}
263
264/// The Router service as frozen into a Workflow snapshot.
265///
266/// Whether it came from a named profile or from the legacy inline member, this
267/// is the single runtime representation. Nothing downstream branches on which
268/// form the operator wrote.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct CapturedReasoningRouter {
271    /// Always [`REASONING_ROUTER_SERVICE_KIND`]. Recorded explicitly so a
272    /// receipt states what kind of thing this is instead of implying it.
273    #[serde(default = "default_service_kind")]
274    pub service_kind: String,
275    /// The Router's id: the profile name, or the inline member's id.
276    pub id: String,
277    /// The origin the definition came from, or [`LEGACY_INLINE_ROUTER_ORIGIN`].
278    #[serde(default = "default_router_origin")]
279    pub origin: String,
280    /// True when this was written inline in the Fleet file rather than saved as
281    /// a reusable profile.
282    #[serde(default)]
283    pub legacy_inline: bool,
284    /// The Router's own exact provider/model.
285    pub route: FrozenRoute,
286    /// What the operator configured this Router's call to run at.
287    #[serde(default)]
288    pub requested_call_reasoning: RouterCallReasoning,
289    /// Always `false`. Stated rather than implied.
290    #[serde(default)]
291    pub dispatchable: bool,
292    /// Always [`PermissionCeiling::ROUTER`].
293    #[serde(default = "router_permissions")]
294    pub permissions: PermissionCeiling,
295}
296
297fn default_service_kind() -> String {
298    REASONING_ROUTER_SERVICE_KIND.to_string()
299}
300
301fn default_router_origin() -> String {
302    LEGACY_INLINE_ROUTER_ORIGIN.to_string()
303}
304
305fn router_permissions() -> PermissionCeiling {
306    PermissionCeiling::ROUTER
307}
308
309impl CapturedReasoningRouter {
310    /// Capture a saved, reusable profile.
311    #[must_use]
312    pub fn from_profile(profile: &ReasoningRouterProfile, origin: impl Into<String>) -> Self {
313        Self {
314            service_kind: default_service_kind(),
315            id: profile.name.clone(),
316            origin: origin.into(),
317            legacy_inline: false,
318            route: FrozenRoute {
319                provider: profile.provider.clone(),
320                model: profile.model.clone(),
321            },
322            requested_call_reasoning: profile.call_reasoning,
323            dispatchable: false,
324            permissions: PermissionCeiling::ROUTER,
325        }
326    }
327
328    /// Normalize the prototype inline form into the same captured service.
329    ///
330    /// The inline member's own `reasoning` was a full [`ReasoningTier`]; it is
331    /// mapped onto the cheap call ceiling here, and anything above `low` is
332    /// rejected by the Fleet parser rather than clamped silently.
333    #[must_use]
334    pub fn from_legacy_inline(member: &RouterMember) -> Self {
335        Self {
336            service_kind: default_service_kind(),
337            id: member.id.clone(),
338            origin: default_router_origin(),
339            legacy_inline: true,
340            route: member.frozen_route(),
341            requested_call_reasoning: member.call_reasoning,
342            dispatchable: false,
343            permissions: PermissionCeiling::ROUTER,
344        }
345    }
346
347    /// `origin/id` — the stable display form a receipt prints.
348    #[must_use]
349    pub fn qualified(&self) -> String {
350        format!("{}/{}", self.origin, self.id)
351    }
352
353    /// A Router is never a worker. Constant, not a policy lookup.
354    #[must_use]
355    pub const fn is_dispatchable(&self) -> bool {
356        false
357    }
358
359    /// The Router's tool surface is empty, always.
360    #[must_use]
361    pub const fn tool_surface(&self) -> &'static [&'static str] {
362        &[]
363    }
364}
365
366/// How a Fleet points at its Router.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case", tag = "kind")]
369pub enum FleetRouterRef {
370    /// A saved, reusable profile named by `reasoning_router = "<name>"`.
371    Profile { name: String },
372    /// The prototype inline `[[members]] kind = "router"` form.
373    LegacyInline(Box<RouterMember>),
374}
375
376fn split_qualified(name: &str) -> (Option<&str>, &str) {
377    let trimmed = name.trim();
378    match trimmed.split_once('/') {
379        Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
380            (Some(origin.trim()), bare.trim())
381        }
382        _ => (None, trimmed),
383    }
384}
385
386fn exact_token(value: &str, router: &str, field: &str) -> Result<String, ReasoningRouterError> {
387    let trimmed = value.trim();
388    if trimmed.is_empty()
389        || trimmed
390            .chars()
391            .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='))
392    {
393        return Err(ReasoningRouterError::InvalidToken {
394            field: format!("{router}.{field}"),
395            value: trimmed.to_string(),
396        });
397    }
398    Ok(trimmed.to_string())
399}
400
401#[derive(Debug, Deserialize)]
402#[serde(deny_unknown_fields)]
403struct RouterProfileToml {
404    name: String,
405    #[serde(default)]
406    description: Option<String>,
407    schema: String,
408    #[serde(default = "default_router_revision")]
409    schema_revision: u32,
410    provider: String,
411    model: String,
412    #[serde(default, alias = "reasoning")]
413    call_reasoning: Option<String>,
414}
415
416const fn default_router_revision() -> u32 {
417    REASONING_ROUTER_SCHEMA_REVISION
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Error)]
421pub enum ReasoningRouterError {
422    #[error("failed to parse reasoning router profile: {0}")]
423    Parse(String),
424    #[error("failed to read reasoning router profile `{path}`: {message}")]
425    Io { path: String, message: String },
426    #[error("reasoning router `{name}` was not found in any configured origin")]
427    NotFound { name: String },
428    #[error(
429        "reasoning router `{name}` is defined in more than one place ({}); a router names an \
430         exact provider/model, so shadowing would silently change which provider sees every \
431         routing summary. Name one explicitly as `origin/{name}`.",
432        origins.join(", ")
433    )]
434    AmbiguousRouter { name: String, origins: Vec<String> },
435    #[error("unknown reasoning router schema `{schema}`; expected `reasoning_router`")]
436    UnknownSchema { schema: String },
437    #[error(
438        "reasoning router schema revision {revision} is not supported (this build reads {supported})"
439    )]
440    UnsupportedRevision { revision: u32, supported: u32 },
441    #[error("{field} must be a non-empty token without whitespace, quotes, or `=` (got `{value}`)")]
442    InvalidToken { field: String, value: String },
443    #[error("reasoning router name mismatch: file declares `{declared}`, expected `{expected}`")]
444    NameMismatch { declared: String, expected: String },
445    #[error(
446        "reasoning router `{router}` requests call reasoning `{value}`; a router may only run at \
447         `off` or `low`. This is rejected rather than clamped: a router answers one tiny JSON \
448         object per task, and running it at `{value}` would spend your tokens on thinking nobody \
449         asked for. Set `call_reasoning` to `off` or `low`."
450    )]
451    CallReasoningTooExpensive { router: String, value: String },
452    #[error(
453        "reasoning router `{router}` has invalid call reasoning `{value}`; expected off or low"
454    )]
455    InvalidCallReasoning { router: String, value: String },
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    const LUNA: &str = r#"
463name = "luna-low"
464description = "GPT-5.6 Luna, called at low"
465schema = "reasoning_router"
466schema_revision = 1
467provider = "openai"
468model = "gpt-5.6-luna"
469call_reasoning = "low"
470"#;
471
472    #[test]
473    fn a_profile_parses_its_exact_route_and_cheap_call_tier() {
474        let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
475        assert_eq!(profile.name, "luna-low");
476        assert_eq!(profile.provider, "openai");
477        assert_eq!(profile.model, "gpt-5.6-luna");
478        assert_eq!(profile.call_reasoning, RouterCallReasoning::Low);
479        assert_eq!(profile.schema_revision, REASONING_ROUTER_SCHEMA_REVISION);
480    }
481
482    #[test]
483    fn call_reasoning_defaults_to_off_when_unset() {
484        let text = LUNA.replace("call_reasoning = \"low\"\n", "");
485        let profile = ReasoningRouterProfile::parse(&text).expect("parse");
486        assert_eq!(profile.call_reasoning, RouterCallReasoning::Off);
487    }
488
489    /// The whole point of the cheap ceiling: an expensive tier is an error the
490    /// operator can see, never a clamp they cannot.
491    #[test]
492    fn medium_high_and_max_are_rejected_not_clamped() {
493        for value in ["medium", "high", "max", "xhigh"] {
494            let text = LUNA.replace("\"low\"", &format!("\"{value}\""));
495            let err = ReasoningRouterProfile::parse(&text)
496                .expect_err("an expensive router tier must be rejected");
497            assert!(
498                matches!(err, ReasoningRouterError::CallReasoningTooExpensive { .. }),
499                "value={value} err={err:?}"
500            );
501            let message = err.to_string();
502            assert!(message.contains("off"), "{message}");
503            assert!(message.contains("low"), "{message}");
504            assert!(
505                !message.contains("clamped to"),
506                "the error must not describe a clamp: {message}"
507            );
508        }
509    }
510
511    #[test]
512    fn an_unknown_tier_is_its_own_error() {
513        let text = LUNA.replace("\"low\"", "\"turbo\"");
514        assert!(matches!(
515            ReasoningRouterProfile::parse(&text).expect_err("garbage"),
516            ReasoningRouterError::InvalidCallReasoning { .. }
517        ));
518    }
519
520    #[test]
521    fn an_unknown_schema_or_future_revision_fails_closed() {
522        let wrong_schema = LUNA.replace("\"reasoning_router\"", "\"exact\"");
523        assert!(matches!(
524            ReasoningRouterProfile::parse(&wrong_schema).expect_err("schema"),
525            ReasoningRouterError::UnknownSchema { .. }
526        ));
527
528        let future = LUNA.replace("schema_revision = 1", "schema_revision = 99");
529        assert!(matches!(
530            ReasoningRouterProfile::parse(&future).expect_err("revision"),
531            ReasoningRouterError::UnsupportedRevision { revision: 99, .. }
532        ));
533    }
534
535    #[test]
536    fn a_captured_profile_holds_no_authority_and_is_never_dispatchable() {
537        let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
538        let captured = CapturedReasoningRouter::from_profile(&profile, "workspace");
539
540        assert_eq!(captured.qualified(), "workspace/luna-low");
541        assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
542        assert!(!captured.legacy_inline);
543        assert!(!captured.is_dispatchable());
544        assert!(captured.tool_surface().is_empty());
545        assert!(!captured.permissions.tools);
546        assert!(!captured.permissions.write);
547        assert!(!captured.permissions.network_tool);
548        assert_eq!(captured.permissions.delegation_depth, 0);
549    }
550
551    /// One saved profile, two Fleets. The service is referenced, not owned.
552    #[test]
553    fn one_saved_profile_serves_more_than_one_fleet() {
554        let tmp = tempfile::tempdir().expect("tmp");
555        std::fs::create_dir_all(tmp.path().join(REASONING_ROUTER_DIR)).expect("dir");
556        std::fs::write(
557            tmp.path().join(REASONING_ROUTER_DIR).join("luna-low.toml"),
558            LUNA,
559        )
560        .expect("write");
561
562        let roots = vec![FleetSearchRoot::new("workspace", tmp.path())];
563        let (first, first_id) =
564            ReasoningRouterProfile::load_by_name("luna-low", &roots).expect("load");
565        let (second, second_id) =
566            ReasoningRouterProfile::load_by_name("workspace/luna-low", &roots).expect("qualified");
567
568        assert_eq!(first, second);
569        assert_eq!(first_id, second_id);
570        assert_eq!(first_id.qualified(), "workspace/luna-low");
571
572        // Two independent captures of the same saved service agree exactly.
573        let a = CapturedReasoningRouter::from_profile(&first, &first_id.origin);
574        let b = CapturedReasoningRouter::from_profile(&second, &second_id.origin);
575        assert_eq!(a, b);
576    }
577
578    /// Bare-name ambiguity across origins must fail; a qualified origin works.
579    #[test]
580    fn a_bare_name_defined_in_two_origins_is_ambiguous_until_qualified() {
581        let tmp = tempfile::tempdir().expect("tmp");
582        let home = tmp.path().join("home");
583        let workspace = tmp.path().join("workspace");
584        for root in [&home, &workspace] {
585            std::fs::create_dir_all(root.join(REASONING_ROUTER_DIR)).expect("dir");
586        }
587        std::fs::write(
588            home.join(REASONING_ROUTER_DIR).join("luna-low.toml"),
589            LUNA.replace("gpt-5.6-luna", "gpt-5.6-luna-mini"),
590        )
591        .expect("home");
592        std::fs::write(
593            workspace.join(REASONING_ROUTER_DIR).join("luna-low.toml"),
594            LUNA,
595        )
596        .expect("workspace");
597
598        let roots = vec![
599            FleetSearchRoot::new("codewhale_home", &home),
600            FleetSearchRoot::new("workspace", &workspace),
601        ];
602
603        let err = ReasoningRouterProfile::load_by_name("luna-low", &roots)
604            .expect_err("bare name must not be resolved by shadowing");
605        assert!(
606            matches!(err, ReasoningRouterError::AmbiguousRouter { .. }),
607            "{err:?}"
608        );
609        let message = err.to_string();
610        assert!(message.contains("codewhale_home"), "{message}");
611        assert!(message.contains("workspace"), "{message}");
612
613        let (workspace_profile, id) =
614            ReasoningRouterProfile::load_by_name("workspace/luna-low", &roots).expect("qualified");
615        assert_eq!(id.qualified(), "workspace/luna-low");
616        assert_eq!(workspace_profile.model, "gpt-5.6-luna");
617
618        let (home_profile, home_id) =
619            ReasoningRouterProfile::load_by_name("codewhale_home/luna-low", &roots)
620                .expect("qualified");
621        assert_eq!(home_id.qualified(), "codewhale_home/luna-low");
622        assert_eq!(home_profile.model, "gpt-5.6-luna-mini");
623    }
624
625    #[test]
626    fn a_missing_profile_is_a_named_error() {
627        let tmp = tempfile::tempdir().expect("tmp");
628        let roots = vec![FleetSearchRoot::new("workspace", tmp.path())];
629        assert!(matches!(
630            ReasoningRouterProfile::load_by_name("nope", &roots).expect_err("missing"),
631            ReasoningRouterError::NotFound { .. }
632        ));
633    }
634
635    /// A captured router serializes into a durable snapshot with no secrets and
636    /// no paths, and older records without the newer fields still read.
637    #[test]
638    fn a_captured_router_is_durable_and_backward_compatible() {
639        let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
640        let captured = CapturedReasoningRouter::from_profile(&profile, "workspace");
641        let json = serde_json::to_string(&captured).expect("serialize");
642        let lowered = json.to_ascii_lowercase();
643        for forbidden in ["api_key", "secret", "token", "bearer", "/users/", ".toml"] {
644            assert!(!lowered.contains(forbidden), "{forbidden} in {json}");
645        }
646        let back: CapturedReasoningRouter = serde_json::from_str(&json).expect("round-trip");
647        assert_eq!(back, captured);
648
649        let older = r#"{"id":"router","route":{"provider":"zai","model":"glm-5-turbo"}}"#;
650        let legacy: CapturedReasoningRouter = serde_json::from_str(older).expect("serde defaults");
651        assert_eq!(legacy.service_kind, REASONING_ROUTER_SERVICE_KIND);
652        assert_eq!(legacy.origin, LEGACY_INLINE_ROUTER_ORIGIN);
653        assert_eq!(legacy.requested_call_reasoning, RouterCallReasoning::Off);
654        assert!(!legacy.dispatchable);
655    }
656}