| 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 |