返回 CodeWhale
reasoning_router.rs
根目录 / crates / workflow / src / 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
41 use std::path::PathBuf;
42
43 use serde::{Deserialize, Serialize};
44 use thiserror::Error;
45
46 use crate::fleet_exact::{FrozenRoute, PermissionCeiling, ReasoningTier, RouterMember};
47 use crate::named_fleet::FleetSearchRoot;
48
49 /// Directory (under each search root) that holds saved Router profiles.
50 pub const REASONING_ROUTER_DIR: &str = "routers";
51 /// Wire value of the `schema` key that selects a Router profile document.
52 pub const REASONING_ROUTER_SCHEMA_KIND: &str = "reasoning_router";
53 /// Current revision of the Router profile schema.
54 pub 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.
57 pub const REASONING_ROUTER_SERVICE_KIND: &str = "reasoning_router";
58 /// Origin recorded for a Router that was written inline in a Fleet file.
59 pub 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")]
69 pub enum RouterCallReasoning {
70 #[default]
71 Off,
72 Low,
73 }
74
75 impl 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)]
121 pub struct QualifiedRouterId {
122 pub name: String,
123 pub origin: String,
124 }
125
126 impl 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)]
138 pub 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
151 impl 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)]
270 pub 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
297 fn default_service_kind() -> String {
298 REASONING_ROUTER_SERVICE_KIND.to_string()
299 }
300
301 fn default_router_origin() -> String {
302 LEGACY_INLINE_ROUTER_ORIGIN.to_string()
303 }
304
305 fn router_permissions() -> PermissionCeiling {
306 PermissionCeiling::ROUTER
307 }
308
309 impl 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")]
369 pub 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
376 fn 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
386 fn 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)]
403 struct 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
416 const fn default_router_revision() -> u32 {
417 REASONING_ROUTER_SCHEMA_REVISION
418 }
419
420 #[derive(Debug, Clone, PartialEq, Eq, Error)]
421 pub 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)]
459 mod tests {
460 use super::*;
461
462 const LUNA: &str = r#"
463 name = "luna-low"
464 description = "GPT-5.6 Luna, called at low"
465 schema = "reasoning_router"
466 schema_revision = 1
467 provider = "openai"
468 model = "gpt-5.6-luna"
469 call_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 }
657
657 lines RUST