返回 CodeWhale
scout.rs
根目录 / crates / tui / src / fleet / scout.rs
1 //! Scout — the one visible fast exploratory role.
2 //!
3 //! Scout is exploration, triage, and quick research. There is exactly one
4 //! concept; the legacy `faster`/`model_strength` control is removed from the
5 //! user-facing surface (its parsing survives for compatibility and maps onto
6 //! the Scout policy).
7 //!
8 //! Route resolution, in order:
9 //!
10 //! 1. **An explicit Scout pin always wins** and survives operator changes.
11 //! 2. **No pin:** a suggested fast companion from explicit catalog metadata —
12 //! the provider's documented cheap sibling via
13 //! [`crate::model_routing::provider_router_candidates`] (DeepSeek
14 //! pro/flash, Z.ai 5.2/5.3 → GLM-5-Turbo, Claude → Haiku, …). The
15 //! suggestion is honored only when that model actually exists in the
16 //! merged catalog for the provider — never invented.
17 //! 3. **No verified companion:** the Scout inherits the session route
18 //! deliberately, and the resolution says so.
19 //! 4. **No session route at all:** `Unavailable` with a precise reason.
20
21 use crate::config::ApiProvider;
22 use crate::fleet::store::FleetMember;
23 use crate::model_routing::provider_router_candidates;
24 use crate::provider_lake::all_catalog_models_for_provider;
25
26 /// Where the resolved Scout route came from. Shown before a run, never
27 /// guessed.
28 #[derive(Debug, Clone, PartialEq, Eq)]
29 pub enum ScoutSource {
30 /// The Fleet pins this exact provider/model.
31 Pinned,
32 /// The provider's documented fast sibling, verified present in the
33 /// catalog for this provider.
34 CatalogSuggestion,
35 /// No pin, no verified companion — the Scout inherits the session route.
36 Inherited,
37 /// No route could be resolved; the reason names why.
38 Unavailable(String),
39 }
40
41 /// The resolved Scout route and its source.
42 #[derive(Debug, Clone, PartialEq, Eq)]
43 pub struct ScoutResolution {
44 pub provider: String,
45 pub model: String,
46 pub source: ScoutSource,
47 }
48
49 impl ScoutResolution {
50 /// A compact one-line receipt: `deepseek/deepseek-v4-flash (pinned)`.
51 #[must_use]
52 pub fn receipt_line(&self) -> String {
53 let source = match &self.source {
54 ScoutSource::Pinned => "pinned",
55 ScoutSource::CatalogSuggestion => "catalog suggestion",
56 ScoutSource::Inherited => "inherits session route",
57 ScoutSource::Unavailable(_) => "unavailable",
58 };
59 format!("{}/{} ({source})", self.provider, self.model)
60 }
61 }
62
63 /// The provider's documented fast sibling for the given session route,
64 /// VERIFIED against the merged catalog. `None` means "no verified fast
65 /// companion exists for this provider/route" — never a guess.
66 #[must_use]
67 pub fn verified_fast_companion(provider_id: &str, session_model: &str) -> Option<(String, String)> {
68 let provider = ApiProvider::parse(provider_id)?;
69 let candidates = provider_router_candidates(provider, session_model);
70 let cheap = candidates.cheap?;
71 // Verification: the suggested model must actually exist as an offering
72 // for this provider in the merged catalog. A table row whose model was
73 // removed (or was never shipped) yields no suggestion.
74 let available = all_catalog_models_for_provider(provider);
75 if available.iter().any(|m| m == &cheap) {
76 Some((provider_id.to_string(), cheap))
77 } else {
78 None
79 }
80 }
81
82 /// Resolve the Scout route for a run. Explicit pins win; otherwise a verified
83 /// catalog companion; otherwise deliberate inheritance; otherwise a precise
84 /// unavailable reason. Never invents a fallback model.
85 #[must_use]
86 pub fn resolve_scout_route(
87 scout_member: Option<&FleetMember>,
88 session_provider: &str,
89 session_model: &str,
90 ) -> ScoutResolution {
91 if let Some(member) = scout_member
92 && let (Some(provider), Some(model)) = (&member.provider, &member.model)
93 {
94 return ScoutResolution {
95 provider: provider.clone(),
96 model: model.clone(),
97 source: ScoutSource::Pinned,
98 };
99 }
100 if let Some((provider, model)) = verified_fast_companion(session_provider, session_model) {
101 return ScoutResolution {
102 provider,
103 model,
104 source: ScoutSource::CatalogSuggestion,
105 };
106 }
107 let session_model = session_model.trim();
108 if session_model.is_empty() || session_model.eq_ignore_ascii_case("auto") {
109 return ScoutResolution {
110 provider: session_provider.to_string(),
111 model: session_model.to_string(),
112 source: ScoutSource::Unavailable(
113 "no verified fast companion exists for this provider and no concrete \
114 session route is set to inherit"
115 .to_string(),
116 ),
117 };
118 }
119 ScoutResolution {
120 provider: session_provider.to_string(),
121 model: session_model.to_string(),
122 source: ScoutSource::Inherited,
123 }
124 }
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129 use crate::fleet::store::FleetMember;
130
131 fn member(pin: Option<(&str, &str)>) -> Option<FleetMember> {
132 pin.map(|(provider, model)| FleetMember {
133 id: "scout".to_string(),
134 role: "scout".to_string(),
135 provider: Some(provider.to_string()),
136 model: Some(model.to_string()),
137 reasoning: None,
138 instructions: None,
139 requires: Vec::new(),
140 })
141 }
142
143 #[test]
144 fn pinned_scout_wins_and_survives_operator_changes() {
145 // The pin is a different route than the session — the pin must win.
146 let resolution = resolve_scout_route(
147 member(Some(("deepseek", "deepseek-v4-pro"))).as_ref(),
148 "deepseek",
149 "deepseek-v4-flash",
150 );
151 assert_eq!(resolution.provider, "deepseek");
152 assert_eq!(resolution.model, "deepseek-v4-pro");
153 assert_eq!(resolution.source, ScoutSource::Pinned);
154
155 // Operator change: still the pin.
156 let resolution = resolve_scout_route(
157 member(Some(("deepseek", "deepseek-v4-pro"))).as_ref(),
158 "openai",
159 "gpt-5",
160 );
161 assert_eq!(resolution.model, "deepseek-v4-pro");
162 assert_eq!(resolution.source, ScoutSource::Pinned);
163 }
164
165 #[test]
166 fn unpinned_scout_gets_verified_catalog_companion_or_inherits() {
167 // DeepSeek's documented cheap sibling is deepseek-v4-flash and it is
168 // in the bundled catalog — a verified suggestion.
169 let resolution = resolve_scout_route(None, "deepseek", "deepseek-v4-pro");
170 assert_eq!(resolution.provider, "deepseek");
171 assert_eq!(resolution.model, "deepseek-v4-flash");
172 assert_eq!(resolution.source, ScoutSource::CatalogSuggestion);
173
174 // Anthropic's Claude models have a documented cheap sibling
175 // (claude-haiku-4-5) — a verified suggestion.
176 let resolution = resolve_scout_route(None, "anthropic", "claude-sonnet-4-6");
177 assert_eq!(resolution.model, "claude-haiku-4-5");
178 assert_eq!(resolution.source, ScoutSource::CatalogSuggestion);
179
180 // A provider outside the companion tables has no verified fast
181 // sibling — deliberate inheritance, never an invented fallback.
182 let resolution = resolve_scout_route(None, "sglang", "some-model");
183 assert_eq!(resolution.model, "some-model");
184 assert_eq!(resolution.source, ScoutSource::Inherited);
185 }
186
187 #[test]
188 fn no_session_route_is_unavailable_with_a_reason() {
189 // No companion for this provider AND no session route to inherit.
190 let resolution = resolve_scout_route(None, "sglang", "");
191 assert!(
192 matches!(&resolution.source, ScoutSource::Unavailable(reason) if !reason.is_empty()),
193 "{resolution:?}"
194 );
195 }
196
197 #[test]
198 fn verified_companion_requires_the_model_in_the_catalog() {
199 // A provider whose router table lists a sibling that is NOT in the
200 // merged catalog yields no suggestion (the runtime verification is
201 // the honesty gate).
202 let resolution = resolve_scout_route(None, "zai", "GLM-5.2");
203 match resolution.source {
204 ScoutSource::CatalogSuggestion => {
205 // GLM-5-Turbo must actually be listed for zai in this build.
206 let available = all_catalog_models_for_provider(ApiProvider::Zai);
207 assert!(
208 available.iter().any(|m| m == "GLM-5-Turbo"),
209 "a CatalogSuggestion must be verifiable in the catalog"
210 );
211 }
212 ScoutSource::Inherited => {
213 // Honest: no verified companion; the scout stays on the
214 // session route.
215 assert_eq!(resolution.model, "GLM-5.2");
216 }
217 other => panic!("unexpected source: {other:?}"),
218 }
219 }
220
221 #[test]
222 fn receipt_line_names_the_source() {
223 let resolution = resolve_scout_route(None, "deepseek", "deepseek-v4-pro");
224 let line = resolution.receipt_line();
225 assert!(line.contains("deepseek-v4-flash"), "{line}");
226 assert!(line.contains("catalog suggestion"), "{line}");
227 }
228 }
229
229 lines RUST