返回 CodeWhale
model_inventory.rs
根目录 / crates / tui / src / model_inventory.rs
1 //! Provider/model inventory for routing policy.
2 //!
3 //! This is the high-level "what can this user actually run?" object. Auto
4 //! routing, fleet workers, and sub-agent policy should consume this shape
5 //! instead of guessing model strings from global defaults.
6
7 use serde::Serialize;
8
9 use crate::config::{
10 ApiProvider, Config, has_api_key_for, normalize_model_name_for_provider, provider_capability,
11 };
12 use crate::provider_lake::models_for_provider;
13
14 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15 #[serde(rename_all = "snake_case")]
16 pub(crate) enum ModelAuthSource {
17 Config,
18 Env,
19 OAuthCli,
20 ImportedToken,
21 NoAuth,
22 KeylessLocal,
23 }
24
25 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26 pub(crate) struct ModelRouteCandidate {
27 pub(crate) provider: ApiProvider,
28 pub(crate) provider_name: &'static str,
29 pub(crate) provider_display_name: &'static str,
30 pub(crate) model: String,
31 /// Explicit declarations keep case-sensitive wire identity; bundled aliases
32 /// retain the existing case-insensitive convenience lookup.
33 #[serde(skip_serializing_if = "std::ops::Not::not")]
34 pub(crate) user_declared: bool,
35 pub(crate) context_window: u32,
36 /// The context window came from the legacy capability fallback (an `_Nk`
37 /// name-suffix parse or a vendor-family heuristic), not a route fact
38 /// (#5441). Serialized only when true so existing payloads stay stable.
39 #[serde(skip_serializing_if = "std::ops::Not::not")]
40 pub(crate) context_window_unverified: bool,
41 /// Known output ceiling, or `None` when this route publishes none. The
42 /// classifier is told "unknown" rather than a fabricated number.
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub(crate) max_output: Option<u32>,
45 pub(crate) thinking_supported: bool,
46 pub(crate) cache_telemetry_supported: bool,
47 pub(crate) auth_source: ModelAuthSource,
48 pub(crate) readiness: crate::provider_readiness::ResolvedProviderReadiness,
49 pub(crate) default_for_provider: bool,
50 pub(crate) tags: Vec<&'static str>,
51 }
52
53 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54 pub(crate) struct ModelInventory {
55 pub(crate) active_provider: ApiProvider,
56 pub(crate) router_provider: ApiProvider,
57 pub(crate) router_model: String,
58 /// Thinking tier for the classifier call (None = off) (#auto.router).
59 pub(crate) router_thinking: Option<String>,
60 /// Classifier call timeout in seconds (default 4; clamped at config load).
61 pub(crate) router_timeout_secs: u64,
62 /// Whether an explicit legacy `[auto.router]` classifier route is
63 /// configured. Absent configuration means legacy Auto stays local/free —
64 /// holding a provider key never elects a network classifier by itself.
65 pub(crate) router_configured: bool,
66 pub(crate) router_available: bool,
67 /// `[auto] cross_provider = true` opt-in (#4411). When false (the
68 /// default), Auto routing — classifier payload included — is confined to
69 /// `active_provider`. The full candidate list still carries every
70 /// authenticated provider because pickers and explicit `/model` lookups
71 /// legitimately need it; only the Auto paths are scoped.
72 pub(crate) cross_provider_auto: bool,
73 pub(crate) candidates: Vec<ModelRouteCandidate>,
74 }
75
76 impl ModelInventory {
77 pub(crate) fn from_config(config: &Config) -> Self {
78 Self::from_config_with_health(
79 config,
80 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
81 )
82 }
83
84 pub(crate) fn from_config_with_health(
85 config: &Config,
86 health: &crate::provider_readiness::ProviderReadinessSnapshot,
87 ) -> Self {
88 let active_provider = config.api_provider();
89 let mut candidates = Vec::new();
90
91 for provider in ApiProvider::all().iter().copied() {
92 let Some(auth_source) = auth_source_for_provider(config, provider) else {
93 continue;
94 };
95 let default_model = provider_default_model(config, provider);
96 let mut models = Vec::<String>::new();
97 if let Some(model) = configured_model_for_provider(config, provider) {
98 push_model(&mut models, provider, &model);
99 }
100 if provider == active_provider {
101 let active_model = config.default_model();
102 if !active_model.trim().eq_ignore_ascii_case("auto") {
103 push_model(&mut models, provider, &active_model);
104 }
105 }
106 for model in models_for_provider(config, active_provider, provider) {
107 push_model(&mut models, provider, &model);
108 }
109 for declaration in config.custom_models.as_deref().unwrap_or_default() {
110 if crate::provider_lake::configured_model_for_route(
111 config,
112 provider,
113 &config.provider_identity_for(provider),
114 &config.base_url_for_route(provider),
115 &declaration.id,
116 )
117 .is_some()
118 && !models.contains(&declaration.id)
119 {
120 models.push(declaration.id.clone());
121 }
122 }
123 if models.is_empty() {
124 push_model(&mut models, provider, &default_model);
125 }
126
127 for model in models {
128 let readiness =
129 crate::provider_readiness::resolve_for_model(config, provider, &model, health);
130 let mut capability = provider_capability(provider, &model);
131 let mut user_declared = false;
132 // #5239/#5441: a candidate whose window came from the legacy
133 // capability fallback (a `_Nk` name-suffix parse or a
134 // vendor-family heuristic) carries the number *and* the fact
135 // that nobody verified it — the auto-router must not read a
136 // guessed window as a route capability.
137 let mut context_window_unverified =
138 codewhale_models::model_catalog::resolved_context_window(&model).is_none();
139 if let Ok(route) =
140 crate::route_runtime::resolve_runtime_route(config, provider, Some(&model))
141 {
142 if let Some(context_window) = route.candidate.limits().context_tokens {
143 capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
144 context_window_unverified = !route.context_window.source.is_verified();
145 }
146 // A concrete offering maximum is a stronger fact than the
147 // static compatibility matrix — and is the only way a
148 // membership route (no static cap) gets a known ceiling.
149 if let Some(max_output) = route
150 .candidate
151 .limits()
152 .output_tokens
153 .and_then(|tokens| u32::try_from(tokens).ok())
154 .filter(|tokens| *tokens > 0)
155 {
156 capability.max_output = Some(max_output);
157 }
158 user_declared = route
159 .candidate
160 .applied_limit_overrides()
161 .iter()
162 .any(|entry| {
163 entry.source
164 == codewhale_config::route::OverrideSource::UserModelMetadata
165 });
166 if user_declared {
167 context_window_unverified = !route.context_window.source.is_verified();
168 capability.context_window = route.context_window.tokens;
169 capability.max_output = route
170 .candidate
171 .limits()
172 .output_tokens
173 .and_then(|value| u32::try_from(value).ok());
174 capability.thinking_supported = route.candidate.capabilities().reasoning
175 == codewhale_config::route::CapabilityState::Supported;
176 }
177 // Do not promote bare `k3` into the global capability
178 // catalog. Its thinking trace contract belongs only to
179 // Kimi Code's exact membership-plan route.
180 if !user_declared
181 && crate::config::is_exact_kimi_code_k3_route(
182 provider,
183 &route.candidate.endpoint().base_url,
184 route.candidate.wire_model_id().as_str(),
185 )
186 {
187 capability.thinking_supported = true;
188 }
189 }
190 let mut tags = Vec::new();
191 if capability.context_window >= 1_000_000 {
192 tags.push("long_context");
193 }
194 if capability.thinking_supported {
195 tags.push("thinking");
196 }
197 if matches!(
198 provider,
199 ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm
200 ) {
201 tags.push("local");
202 }
203 // Unready routes stay visible (annotated) so an operator can
204 // override explicitly, but they are never a silent default.
205 let default_for_provider = readiness.can_attempt()
206 && (model == default_model
207 || (!user_declared && model.eq_ignore_ascii_case(&default_model)));
208 if default_for_provider {
209 tags.push("default");
210 }
211 if !readiness.can_attempt() {
212 tags.push("unready");
213 }
214
215 candidates.push(ModelRouteCandidate {
216 provider,
217 provider_name: provider.as_str(),
218 provider_display_name: provider.display_name(),
219 default_for_provider,
220 model,
221 user_declared,
222 context_window: capability.context_window,
223 context_window_unverified,
224 max_output: capability.max_output,
225 thinking_supported: capability.thinking_supported,
226 cache_telemetry_supported: capability.cache_telemetry_supported,
227 auth_source: auth_source.clone(),
228 readiness: readiness.clone(),
229 tags,
230 });
231 }
232 }
233
234 // `[auto.router]` is legacy `model = auto` configuration and stays that
235 // way — it is NOT a Fleet Router. Explicit configuration still works.
236 //
237 // What is gone is the implicit half: merely holding a DeepSeek key used
238 // to silently elect `deepseek-v4-flash` as a network classifier for
239 // every Auto turn, spending a user's tokens on a route they never asked
240 // for and privileging one provider. With no explicit `[auto.router]`,
241 // legacy Auto is now local/free (heuristic-only).
242 let explicit_router = config
243 .auto
244 .as_ref()
245 .and_then(|auto| auto.router.as_ref())
246 .and_then(|router| {
247 let provider = router.provider.as_deref().and_then(ApiProvider::parse)?;
248 let model = router
249 .model
250 .as_deref()
251 .map(str::trim)
252 .filter(|m| !m.is_empty())?;
253 Some((
254 provider,
255 model.to_string(),
256 router
257 .thinking
258 .as_deref()
259 .map(str::trim)
260 .filter(|t| !t.is_empty())
261 .map(str::to_string),
262 ))
263 });
264 let router_configured = explicit_router.is_some();
265 let (router_provider, router_model, router_thinking) = explicit_router
266 // Kept only as an inert display/default label for the router fields;
267 // `router_available` below is what gates any classifier call.
268 .unwrap_or_else(|| (ApiProvider::Deepseek, "deepseek-v4-flash".to_string(), None));
269
270 let cross_provider_auto = config.auto_cross_provider();
271 let router_timeout_secs = config.auto_router_timeout_secs();
272
273 Self {
274 active_provider,
275 router_provider,
276 router_configured,
277 router_available: router_configured && has_api_key_for(config, router_provider),
278 router_model,
279 router_thinking,
280 router_timeout_secs,
281 cross_provider_auto,
282 candidates,
283 }
284 }
285
286 /// Whether Auto routing may select `provider` (#4411).
287 pub(crate) fn auto_scope_allows(&self, provider: ApiProvider) -> bool {
288 self.cross_provider_auto || provider == self.active_provider
289 }
290
291 pub(crate) fn candidate(
292 &self,
293 provider: ApiProvider,
294 model: &str,
295 ) -> Option<&ModelRouteCandidate> {
296 let model = model.trim();
297 self.candidates
298 .iter()
299 .find(|candidate| candidate.provider == provider && candidate.model == model)
300 .or_else(|| {
301 self.candidates.iter().find(|candidate| {
302 candidate.provider == provider
303 && !candidate.user_declared
304 && candidate.model.eq_ignore_ascii_case(model)
305 })
306 })
307 }
308
309 pub(crate) fn active_default(&self) -> Option<&ModelRouteCandidate> {
310 self.candidates
311 .iter()
312 .find(|candidate| {
313 candidate.provider == self.active_provider && candidate.default_for_provider
314 })
315 .or_else(|| {
316 self.candidates.iter().find(|candidate| {
317 candidate.provider == self.active_provider && candidate.readiness.can_attempt()
318 })
319 })
320 .or_else(|| {
321 // Falling through to another provider is a cross-provider Auto
322 // route (#4411): allowed only under the persisted opt-in. With
323 // it off, an unusable active provider surfaces as "no runnable
324 // candidate" instead of silently borrowing another provider's
325 // credentials.
326 self.cross_provider_auto
327 .then(|| {
328 self.candidates
329 .iter()
330 .find(|candidate| candidate.readiness.can_attempt())
331 })
332 .flatten()
333 })
334 }
335
336 pub(crate) fn router_context_json(&self) -> String {
337 #[derive(Serialize)]
338 struct RouterInventoryContext<'a> {
339 active_provider: ApiProvider,
340 candidates: Vec<RouterCandidateContext<'a>>,
341 }
342
343 #[derive(Serialize)]
344 struct RouterCandidateContext<'a> {
345 provider: ApiProvider,
346 provider_name: &'a str,
347 provider_display_name: &'a str,
348 model: &'a str,
349 context_window: u32,
350 #[serde(skip_serializing_if = "std::ops::Not::not")]
351 context_window_unverified: bool,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 max_output: Option<u32>,
354 thinking_supported: bool,
355 cache_telemetry_supported: bool,
356 default_for_provider: bool,
357 tags: &'a [&'static str],
358 }
359
360 // The classifier needs route capabilities, not credentials, endpoint
361 // configuration, or provider error text. Filter to runnable candidates
362 // and project only non-secret routing facts before serializing.
363 //
364 // Scope (#4411): without the persisted `[auto] cross_provider` opt-in,
365 // the payload names only the active provider's routes. Which other
366 // providers a user has credentials for is not something Auto discloses
367 // to a classifier by default.
368 let candidates = self
369 .candidates
370 .iter()
371 .filter(|candidate| {
372 candidate.readiness.can_attempt() && self.auto_scope_allows(candidate.provider)
373 })
374 .map(|candidate| RouterCandidateContext {
375 provider: candidate.provider,
376 provider_name: candidate.provider_name,
377 provider_display_name: candidate.provider_display_name,
378 model: &candidate.model,
379 context_window: candidate.context_window,
380 context_window_unverified: candidate.context_window_unverified,
381 max_output: candidate.max_output,
382 thinking_supported: candidate.thinking_supported,
383 cache_telemetry_supported: candidate.cache_telemetry_supported,
384 default_for_provider: candidate.default_for_provider,
385 tags: &candidate.tags,
386 })
387 .collect();
388 serde_json::to_string(&RouterInventoryContext {
389 active_provider: self.active_provider,
390 candidates,
391 })
392 .unwrap_or_else(|_| "{}".to_string())
393 }
394 }
395
396 fn push_model(models: &mut Vec<String>, provider: ApiProvider, model: &str) {
397 if provider == ApiProvider::Ollama && crate::config::is_unresolved_local_ollama_model(model) {
398 return;
399 }
400 let Some(model) = normalize_model_name_for_provider(provider, model)
401 .or_else(|| crate::config::normalize_custom_model_id(model))
402 else {
403 return;
404 };
405 if !models
406 .iter()
407 .any(|existing| existing.eq_ignore_ascii_case(&model))
408 {
409 models.push(model);
410 }
411 }
412
413 fn configured_model_for_provider(config: &Config, provider: ApiProvider) -> Option<String> {
414 config
415 .provider_config_for(provider)
416 .and_then(|entry| entry.model.clone())
417 .map(|model| model.trim().to_string())
418 .filter(|model| !model.is_empty())
419 }
420
421 pub(crate) fn provider_default_model(config: &Config, provider: ApiProvider) -> String {
422 let configured = configured_model_for_provider(config, provider).or_else(|| {
423 (provider == config.api_provider() && config.default_text_model.is_some())
424 .then(|| config.default_model())
425 });
426 let selector = configured.as_deref().filter(|model| {
427 !model.trim().eq_ignore_ascii_case("auto")
428 && !(provider == ApiProvider::Ollama
429 && crate::config::is_unresolved_local_ollama_model(model))
430 });
431 // Inventory labels must use the executable route's exact endpoint default,
432 // not whichever provider-wide snapshot happened to refresh most recently.
433 crate::route_runtime::resolve_runtime_route(config, provider, selector)
434 .map(|route| route.model)
435 .unwrap_or_else(|_| {
436 configured.unwrap_or_else(|| {
437 provider
438 .kind()
439 .map(|kind| kind.provider().default_model())
440 .unwrap_or(crate::config::DEFAULT_TEXT_MODEL)
441 .to_string()
442 })
443 })
444 }
445
446 fn auth_source_for_provider(config: &Config, provider: ApiProvider) -> Option<ModelAuthSource> {
447 let credential_state =
448 crate::provider_readiness::credential_state_for_provider(config, provider);
449 match credential_state {
450 crate::provider_readiness::CredentialState::NoAuth => {
451 return Some(ModelAuthSource::NoAuth);
452 }
453 crate::provider_readiness::CredentialState::Local => {
454 return Some(ModelAuthSource::KeylessLocal);
455 }
456 crate::provider_readiness::CredentialState::ImportedToken => {
457 return Some(ModelAuthSource::ImportedToken);
458 }
459 crate::provider_readiness::CredentialState::MissingKey
460 | crate::provider_readiness::CredentialState::MissingLogin
461 | crate::provider_readiness::CredentialState::ExternalConsent
462 | crate::provider_readiness::CredentialState::Legacy => return None,
463 crate::provider_readiness::CredentialState::Saved => {}
464 }
465
466 if provider == ApiProvider::Custom {
467 let configured = config.provider_config_for(provider)?;
468 if configured
469 .api_key_env
470 .as_deref()
471 .map(str::trim)
472 .filter(|name| !name.is_empty())
473 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
474 {
475 return Some(ModelAuthSource::Env);
476 }
477 return (configured.api_key.as_deref().is_some_and(|value| {
478 crate::config::classify_config_api_key_value(value)
479 == crate::config::ConfigApiKeyValueKind::Literal
480 }) || crate::config::explicit_cli_api_key_override().is_some())
481 .then_some(ModelAuthSource::Config);
482 }
483 if provider_uses_oauth_cli(config, provider) {
484 return Some(ModelAuthSource::OAuthCli);
485 }
486 if config
487 .provider_config_for(provider)
488 .and_then(|entry| entry.api_key_env.as_deref())
489 .map(str::trim)
490 .filter(|name| !name.is_empty())
491 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
492 {
493 return Some(ModelAuthSource::Env);
494 }
495 if !config.should_skip_secret_store_for_provider(provider) && env_has_key_for(provider) {
496 return Some(ModelAuthSource::Env);
497 }
498 Some(ModelAuthSource::Config)
499 }
500
501 fn provider_uses_oauth_cli(config: &Config, provider: ApiProvider) -> bool {
502 if config.provider_uses_custom_endpoint(provider) {
503 return false;
504 }
505 match provider {
506 ApiProvider::OpenaiCodex => true,
507 ApiProvider::Xai => config
508 .provider_config_for(provider)
509 .and_then(|entry| entry.auth_mode.as_deref())
510 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth),
511 _ => false,
512 }
513 }
514
515 fn env_has_key_for(provider: ApiProvider) -> bool {
516 env_keys_for_provider(provider)
517 .iter()
518 .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
519 }
520
521 fn env_keys_for_provider(provider: ApiProvider) -> &'static [&'static str] {
522 provider.env_vars()
523 }
524
525 #[cfg(test)]
526 mod tests {
527 use super::*;
528
529 #[test]
530 fn inventory_env_keys_follow_provider_metadata() {
531 for provider in ApiProvider::all() {
532 assert_eq!(env_keys_for_provider(*provider), provider.env_vars());
533 }
534 }
535
536 #[test]
537 fn inventory_includes_only_usable_authenticated_providers() {
538 let _env_lock = crate::test_support::lock_test_env();
539 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
540 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
541 let _minimax = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
542 let config = Config {
543 provider: Some("zai".to_string()),
544 default_text_model: Some("deepseek-v4-pro".to_string()),
545 ..Default::default()
546 };
547
548 let inventory = ModelInventory::from_config(&config);
549
550 // A DeepSeek key alone no longer elects a network classifier: with no
551 // explicit `[auto.router]`, legacy Auto stays local/free.
552 assert!(!inventory.router_configured);
553 assert!(!inventory.router_available);
554 assert!(
555 inventory
556 .candidate(ApiProvider::Zai, crate::config::ZAI_GLM_5_2_MODEL)
557 .is_some()
558 );
559 assert!(
560 inventory
561 .candidates
562 .iter()
563 .all(|candidate| candidate.provider != ApiProvider::Minimax)
564 );
565 }
566
567 #[test]
568 fn inventory_marks_local_providers_keyless() {
569 let _env_lock = crate::test_support::lock_test_env();
570 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
571 let mut config = Config::default();
572 config.set_provider_model_override(ApiProvider::Ollama, Some("local-tag:latest".into()));
573
574 let inventory = ModelInventory::from_config(&config);
575
576 assert!(
577 inventory
578 .candidates
579 .iter()
580 .any(|candidate| candidate.provider == ApiProvider::Ollama
581 && candidate.auth_source == ModelAuthSource::KeylessLocal)
582 );
583 }
584
585 #[test]
586 fn inventory_never_marks_ollama_cloud_keyless_or_local() {
587 let _env_lock = crate::test_support::lock_test_env();
588 let _cloud_env = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
589 let _official_env = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
590 let config = Config {
591 provider: Some("ollama-cloud".to_string()),
592 providers: Some(crate::config::ProvidersConfig {
593 ollama_cloud: crate::config::ProviderConfig {
594 api_key: Some("cloud-key".to_string()),
595 ..Default::default()
596 },
597 ..Default::default()
598 }),
599 ..Default::default()
600 };
601
602 let inventory = ModelInventory::from_config(&config);
603 let candidate = inventory
604 .candidate(
605 ApiProvider::OllamaCloud,
606 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL,
607 )
608 .expect("authenticated Ollama Cloud candidate");
609 assert_eq!(candidate.auth_source, ModelAuthSource::Config);
610 assert!(!candidate.tags.contains(&"local"));
611 assert_ne!(candidate.readiness.label(), "local · not checked");
612 }
613
614 #[test]
615 fn inventory_never_admits_kimi_cli_oauth_import() {
616 let _env_lock = crate::test_support::lock_test_env();
617 let temp = tempfile::tempdir().expect("Kimi import fixture root");
618 let kimi_home = temp.path().join("kimi-code");
619 std::fs::create_dir_all(kimi_home.join("credentials")).expect("Kimi credential directory");
620 let expires_at = std::time::SystemTime::now()
621 .duration_since(std::time::UNIX_EPOCH)
622 .expect("clock after epoch")
623 .as_secs_f64()
624 + 3600.0;
625 let credential_path = kimi_home.join("credentials/kimi-code.json");
626 let credential_raw = serde_json::json!({
627 "access_token": "unexpired-user-owned-token",
628 "refresh_token": "must-not-be-used",
629 "expires_at": expires_at,
630 })
631 .to_string();
632 std::fs::write(&credential_path, &credential_raw).expect("write Kimi import fixture");
633 let _kimi_home = crate::test_support::EnvVarGuard::set(
634 "KIMI_CODE_HOME",
635 kimi_home.to_str().expect("utf8 path"),
636 );
637 let config = Config {
638 provider: Some("moonshot".to_string()),
639 providers: Some(crate::config::ProvidersConfig {
640 moonshot: crate::config::ProviderConfig {
641 auth_mode: Some("kimi_oauth".to_string()),
642 ..Default::default()
643 },
644 ..Default::default()
645 }),
646 ..Default::default()
647 };
648
649 let inventory = ModelInventory::from_config(&config);
650 assert!(
651 inventory
652 .candidates
653 .iter()
654 .all(|candidate| candidate.provider != ApiProvider::Moonshot),
655 "unsupported Kimi CLI OAuth must not enter the routing inventory"
656 );
657 assert_eq!(
658 std::fs::read_to_string(credential_path).expect("Kimi file remains untouched"),
659 credential_raw
660 );
661 }
662
663 #[test]
664 fn inventory_uses_kimi_code_k3_route_context_not_generic_fallback() {
665 let config = Config {
666 provider: Some("moonshot".to_string()),
667 providers: Some(crate::config::ProvidersConfig {
668 moonshot: crate::config::ProviderConfig {
669 api_key: Some("test-kimi-key".to_string()),
670 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
671 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
672 ..Default::default()
673 },
674 ..Default::default()
675 }),
676 ..Default::default()
677 };
678
679 let inventory = ModelInventory::from_config(&config);
680 let candidate = inventory
681 .candidate(ApiProvider::Moonshot, crate::config::KIMI_CODE_K3_MODEL)
682 .expect("configured Kimi Code K3 route");
683
684 assert_eq!(candidate.context_window, 262_144);
685 assert!(candidate.thinking_supported);
686 assert!(candidate.tags.contains(&"thinking"));
687 assert!(!candidate.tags.contains(&"long_context"));
688 }
689
690 /// #5441: the auto-router inventory carries a `_Nk` name-suffix window
691 /// together with the fact that nobody verified it, so a classifier never
692 /// reads a naming convention as a route capability.
693 #[test]
694 fn router_inventory_marks_name_suffix_windows_unverified() {
695 let config = Config {
696 provider: Some("vllm".to_string()),
697 providers: Some(crate::config::ProvidersConfig {
698 vllm: crate::config::ProviderConfig {
699 base_url: Some("http://localhost:8000/v1".to_string()),
700 model: Some("qwen3-32b-256k".to_string()),
701 ..Default::default()
702 },
703 ..Default::default()
704 }),
705 ..Default::default()
706 };
707
708 let inventory = ModelInventory::from_config(&config);
709 let candidate = inventory
710 .candidate(ApiProvider::Vllm, "qwen3-32b-256k")
711 .expect("configured self-hosted route");
712 assert_eq!(candidate.context_window, 256_000);
713 assert!(
714 candidate.context_window_unverified,
715 "a name-suffix window must not enter the router payload as a fact"
716 );
717
718 let payload = inventory.router_context_json();
719 assert!(
720 payload.contains("\"context_window_unverified\":true"),
721 "payload must serialize the marker: {payload}"
722 );
723 }
724
725 #[test]
726 fn inventory_includes_custom_api_key_env_route() {
727 let _env_lock = crate::test_support::lock_test_env();
728 let _custom_key = crate::test_support::EnvVarGuard::set("ACME_CUSTOM_KEY", "custom-key");
729 let config = Config {
730 provider: Some("acme".to_string()),
731 providers: Some(crate::config::ProvidersConfig {
732 custom: std::collections::HashMap::from([(
733 "acme".to_string(),
734 crate::config::ProviderConfig {
735 kind: Some("openai-compatible".to_string()),
736 base_url: Some("https://api.acme.test/v1".to_string()),
737 model: Some("acme-coder".to_string()),
738 api_key_env: Some("ACME_CUSTOM_KEY".to_string()),
739 ..Default::default()
740 },
741 )]),
742 ..Default::default()
743 }),
744 ..Default::default()
745 };
746
747 let inventory = ModelInventory::from_config(&config);
748 assert!(
749 inventory
750 .candidates
751 .iter()
752 .any(|candidate| candidate.provider == ApiProvider::Custom
753 && candidate.model == "acme-coder"
754 && candidate.auth_source == ModelAuthSource::Env)
755 );
756 }
757
758 #[test]
759 fn inventory_router_timeout_secs_respects_config_with_clamp() {
760 let _env_lock = crate::test_support::lock_test_env();
761
762 // Unset: the legacy default (4 s) survives.
763 let config = Config {
764 ..Default::default()
765 };
766 assert_eq!(ModelInventory::from_config(&config).router_timeout_secs, 4);
767
768 // Explicit value is honored.
769 let config = Config {
770 auto: Some(crate::config::AutoConfig {
771 router: Some(crate::config::AutoRouterConfig {
772 provider: Some("custom".to_string()),
773 model: Some("local-router".to_string()),
774 thinking: None,
775 timeout_secs: Some(15),
776 }),
777 ..Default::default()
778 }),
779 ..Default::default()
780 };
781 assert_eq!(ModelInventory::from_config(&config).router_timeout_secs, 15);
782
783 // Out-of-range values clamp to the safety ceiling, never to zero.
784 let config = Config {
785 auto: Some(crate::config::AutoConfig {
786 router: Some(crate::config::AutoRouterConfig {
787 provider: Some("custom".to_string()),
788 model: Some("local-router".to_string()),
789 thinking: None,
790 timeout_secs: Some(9_999),
791 }),
792 ..Default::default()
793 }),
794 ..Default::default()
795 };
796 assert_eq!(
797 ModelInventory::from_config(&config).router_timeout_secs,
798 crate::config::MAX_AUTO_ROUTER_TIMEOUT_SECS
799 );
800
801 // Zero means "use the default", not an instant timeout.
802 let config = Config {
803 auto: Some(crate::config::AutoConfig {
804 router: Some(crate::config::AutoRouterConfig {
805 provider: Some("custom".to_string()),
806 model: Some("local-router".to_string()),
807 thinking: None,
808 timeout_secs: Some(0),
809 }),
810 ..Default::default()
811 }),
812 ..Default::default()
813 };
814 assert_eq!(ModelInventory::from_config(&config).router_timeout_secs, 4);
815 }
816
817 #[test]
818 fn inventory_ignores_unresolved_command_and_secret_auth_metadata() {
819 let _env_lock = crate::test_support::lock_test_env();
820 let temp = tempfile::tempdir().expect("isolated credential home");
821 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
822 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
823 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
824 let _openai = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
825 let _xai = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
826 let mut providers = crate::config::ProvidersConfig::default();
827 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
828 source: codewhale_config::AuthSourceKind::Command,
829 command: vec!["secret-tool".to_string(), "lookup".to_string()],
830 timeout_ms: Some(2000),
831 secret_id: None,
832 });
833 providers.xai.auth = Some(codewhale_config::ProviderAuthSourceToml {
834 source: codewhale_config::AuthSourceKind::Secret,
835 command: Vec::new(),
836 timeout_ms: None,
837 secret_id: Some("codewhale/xai".to_string()),
838 });
839 let config = Config {
840 provider: Some("openai".to_string()),
841 providers: Some(providers),
842 ..Default::default()
843 };
844
845 let inventory = ModelInventory::from_config(&config);
846 assert!(inventory.candidates.iter().all(|candidate| !matches!(
847 candidate.provider,
848 ApiProvider::Openai | ApiProvider::Xai
849 )));
850 }
851
852 #[test]
853 fn auto_router_config_overrides_default_classifier_route() {
854 let config = Config {
855 auto: Some(crate::config::AutoConfig {
856 cost_saving: None,
857 cross_provider: None,
858 router: Some(crate::config::AutoRouterConfig {
859 provider: Some("zai".to_string()),
860 model: Some("glm-5-turbo".to_string()),
861 thinking: Some("low".to_string()),
862 timeout_secs: None,
863 }),
864 }),
865 ..Default::default()
866 };
867
868 let inventory = ModelInventory::from_config(&config);
869 assert!(inventory.router_configured);
870 assert_eq!(inventory.router_provider, ApiProvider::Zai);
871 assert_eq!(inventory.router_model, "glm-5-turbo");
872 assert_eq!(inventory.router_thinking.as_deref(), Some("low"));
873 }
874
875 /// A DeepSeek key must never, on its own, turn on a network classifier.
876 /// `[auto.router]` stays legacy `model = auto` configuration; absent it,
877 /// legacy Auto is local/free.
878 #[test]
879 fn a_deepseek_key_alone_never_elects_an_implicit_flash_classifier() {
880 let _env_lock = crate::test_support::lock_test_env();
881 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
882 let config = Config {
883 provider: Some("deepseek".to_string()),
884 ..Default::default()
885 };
886
887 let inventory = ModelInventory::from_config(&config);
888
889 assert!(
890 !inventory.router_configured,
891 "no [auto.router] means no configured classifier"
892 );
893 assert!(
894 !inventory.router_available,
895 "holding a DeepSeek key must not silently select deepseek-v4-flash as a classifier"
896 );
897 }
898
899 #[test]
900 fn an_explicit_legacy_auto_router_still_works_when_its_key_is_present() {
901 let _env_lock = crate::test_support::lock_test_env();
902 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
903 let config = Config {
904 auto: Some(crate::config::AutoConfig {
905 cost_saving: None,
906 router: Some(crate::config::AutoRouterConfig {
907 provider: Some("zai".to_string()),
908 model: Some("glm-5-turbo".to_string()),
909 thinking: None,
910 timeout_secs: None,
911 }),
912 cross_provider: None,
913 }),
914 ..Default::default()
915 };
916
917 let inventory = ModelInventory::from_config(&config);
918
919 assert!(inventory.router_configured);
920 assert!(inventory.router_available);
921 assert_eq!(inventory.router_model, "glm-5-turbo");
922 }
923
924 #[test]
925 fn inventory_marks_explicit_no_auth_separately_from_keyless_local() {
926 let mut providers = crate::config::ProvidersConfig::default();
927 providers.vllm.auth_mode = Some("none".to_string());
928 providers.vllm.model = Some("local-model".to_string());
929 let config = Config {
930 provider: Some("vllm".to_string()),
931 providers: Some(providers),
932 ..Default::default()
933 };
934
935 let inventory = ModelInventory::from_config(&config);
936 let candidate = inventory
937 .candidates
938 .iter()
939 .find(|candidate| {
940 candidate.provider == ApiProvider::Vllm && candidate.model == "local-model"
941 })
942 .expect("vLLM no-auth candidate");
943
944 assert_eq!(candidate.auth_source, ModelAuthSource::NoAuth);
945 assert_eq!(
946 candidate.readiness,
947 crate::provider_readiness::ResolvedProviderReadiness::NoAuthUnchecked
948 );
949 }
950
951 #[test]
952 fn unready_candidates_are_never_provider_defaults() {
953 use crate::provider_readiness::ResolvedProviderReadiness;
954
955 let candidate = ModelRouteCandidate {
956 provider: ApiProvider::Openai,
957 provider_name: "openai",
958 provider_display_name: "OpenAI",
959 model: "gpt-5.5".to_string(),
960 context_window: 128_000,
961 context_window_unverified: false,
962 user_declared: false,
963 max_output: Some(16_384),
964 thinking_supported: true,
965 cache_telemetry_supported: false,
966 auth_source: ModelAuthSource::Config,
967 readiness: ResolvedProviderReadiness::MissingLogin,
968 default_for_provider: false,
969 tags: vec!["unready"],
970 };
971 assert!(!candidate.readiness.can_attempt());
972 assert!(!candidate.default_for_provider);
973 assert!(candidate.tags.contains(&"unready"));
974 }
975
976 #[test]
977 fn active_default_never_falls_back_to_unready_candidate() {
978 let inventory = ModelInventory {
979 active_provider: ApiProvider::Openai,
980 router_provider: ApiProvider::Deepseek,
981 router_model: "deepseek-v4-flash".to_string(),
982 router_thinking: None,
983 router_timeout_secs: 4,
984 router_configured: false,
985 router_available: false,
986 cross_provider_auto: false,
987 candidates: vec![ModelRouteCandidate {
988 provider: ApiProvider::Openai,
989 provider_name: "openai",
990 provider_display_name: "OpenAI",
991 model: "unsupported-model".to_string(),
992 context_window: 1,
993 context_window_unverified: false,
994 user_declared: false,
995 max_output: Some(1),
996 thinking_supported: false,
997 cache_telemetry_supported: false,
998 auth_source: ModelAuthSource::Config,
999 readiness: crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
1000 default_for_provider: false,
1001 tags: vec!["unready"],
1002 }],
1003 };
1004
1005 assert!(inventory.active_default().is_none());
1006 }
1007
1008 #[test]
1009 fn router_context_is_runnable_and_redacts_auth_and_failure_details() {
1010 let _env_lock = crate::test_support::lock_test_env();
1011 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1012 let mut inventory = ModelInventory::from_config(&Config::default());
1013 let candidate = inventory
1014 .candidates
1015 .iter_mut()
1016 .find(|candidate| candidate.provider == ApiProvider::Deepseek)
1017 .expect("DeepSeek inventory candidate");
1018 candidate.readiness =
1019 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed {
1020 category: crate::error_taxonomy::ErrorCategory::Authentication,
1021 message: "Bearer super-secret-router-token".to_string(),
1022 };
1023 inventory.candidates.push(ModelRouteCandidate {
1024 provider: ApiProvider::Openai,
1025 provider_name: "openai",
1026 provider_display_name: "OpenAI",
1027 model: "unsupported-model".to_string(),
1028 context_window: 1,
1029 context_window_unverified: false,
1030 user_declared: false,
1031 max_output: Some(1),
1032 thinking_supported: false,
1033 cache_telemetry_supported: false,
1034 auth_source: ModelAuthSource::Config,
1035 readiness: crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
1036 default_for_provider: false,
1037 tags: vec!["unready"],
1038 });
1039
1040 let json = inventory.router_context_json();
1041
1042 assert!(json.contains("deepseek-v4"));
1043 assert!(!json.contains("super-secret-router-token"));
1044 assert!(!json.contains("auth_source"));
1045 assert!(!json.contains("unsupported-model"));
1046 }
1047
1048 #[test]
1049 fn router_context_names_only_the_active_provider_by_default() {
1050 // #4411: a Z.ai session with a DeepSeek key in the environment must
1051 // not disclose the DeepSeek routes — or the fact that a DeepSeek
1052 // credential exists — to the classifier.
1053 let _env_lock = crate::test_support::lock_test_env();
1054 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1055 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1056 let config = Config {
1057 provider: Some("zai".to_string()),
1058 ..Default::default()
1059 };
1060
1061 let inventory = ModelInventory::from_config(&config);
1062 assert!(
1063 inventory
1064 .candidates
1065 .iter()
1066 .any(|candidate| candidate.provider == ApiProvider::Deepseek),
1067 "the full inventory still knows about DeepSeek for pickers/explicit routes"
1068 );
1069
1070 let json = inventory.router_context_json();
1071 let payload: serde_json::Value =
1072 serde_json::from_str(&json).expect("router context is JSON");
1073 let providers: Vec<&str> = payload["candidates"]
1074 .as_array()
1075 .expect("candidate array")
1076 .iter()
1077 .map(|candidate| candidate["provider_name"].as_str().expect("provider name"))
1078 .collect();
1079
1080 assert!(!providers.is_empty(), "active provider routes must remain");
1081 assert!(
1082 providers.iter().all(|provider| *provider == "zai"),
1083 "classifier payload leaked another provider: {json}"
1084 );
1085 assert!(!json.contains("deepseek"), "{json}");
1086 }
1087
1088 #[test]
1089 fn router_context_includes_other_providers_under_persisted_opt_in() {
1090 let _env_lock = crate::test_support::lock_test_env();
1091 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1092 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1093 let config = Config {
1094 provider: Some("zai".to_string()),
1095 auto: Some(crate::config::AutoConfig {
1096 cost_saving: None,
1097 cross_provider: Some(true),
1098 router: None,
1099 }),
1100 ..Default::default()
1101 };
1102
1103 let json = ModelInventory::from_config(&config).router_context_json();
1104
1105 assert!(json.contains("\"zai\""), "{json}");
1106 assert!(json.contains("deepseek"), "{json}");
1107 }
1108
1109 #[test]
1110 fn implicit_deepseek_classifier_is_out_of_scope_for_another_active_provider() {
1111 // #4411: the default classifier route is DeepSeek flash. Calling it
1112 // from a Z.ai session would send the turn's prompt to a second
1113 // provider, so it stays unavailable without an explicit opt-in.
1114 let _env_lock = crate::test_support::lock_test_env();
1115 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1116 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1117 let zai = Config {
1118 provider: Some("zai".to_string()),
1119 ..Default::default()
1120 };
1121 assert!(!ModelInventory::from_config(&zai).router_available);
1122
1123 // `cross_provider = true` widens which candidates Auto may pick; it is
1124 // NOT a classifier election. With the implicit DeepSeek-flash default
1125 // removed, no network classifier runs without an explicit
1126 // `[auto.router]` route — a scope opt-in alone stays local/free.
1127 let opted_in = Config {
1128 auto: Some(crate::config::AutoConfig {
1129 cost_saving: None,
1130 cross_provider: Some(true),
1131 router: None,
1132 }),
1133 ..zai.clone()
1134 };
1135 let widened = ModelInventory::from_config(&opted_in);
1136 assert!(!widened.router_available);
1137 assert!(widened.auto_scope_allows(ApiProvider::Deepseek));
1138
1139 // An explicitly configured `[auto.router]` is itself a persisted
1140 // opt-in for that classifier route.
1141 let explicit_router = Config {
1142 auto: Some(crate::config::AutoConfig {
1143 cost_saving: None,
1144 cross_provider: None,
1145 router: Some(crate::config::AutoRouterConfig {
1146 provider: Some("deepseek".to_string()),
1147 model: Some("deepseek-v4-flash".to_string()),
1148 thinking: None,
1149 timeout_secs: None,
1150 }),
1151 }),
1152 ..zai.clone()
1153 };
1154 assert!(ModelInventory::from_config(&explicit_router).router_available);
1155
1156 // A DeepSeek session gets no free classifier either: with the
1157 // implicit flash default removed, only an explicit `[auto.router]`
1158 // elects a network classifier, active provider or not.
1159 let deepseek = Config {
1160 provider: Some("deepseek".to_string()),
1161 ..Default::default()
1162 };
1163 assert!(!ModelInventory::from_config(&deepseek).router_available);
1164 }
1165
1166 #[test]
1167 fn declared_inventory_ids_remain_case_distinct() {
1168 let _env = crate::test_support::lock_test_env();
1169 let home = tempfile::tempdir().unwrap();
1170 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1171 let mut config: Config = toml::from_str(include_str!(
1172 "../../config/tests/fixtures/custom_models.toml"
1173 ))
1174 .unwrap();
1175 let declaration = config.custom_models.as_mut().unwrap().first_mut().unwrap();
1176 declaration.id = "Preview-fixture".into();
1177 let mut other = declaration.clone();
1178 other.id = "preview-fixture".into();
1179 other.limit.as_mut().unwrap().context = Some(128000);
1180 config.custom_models.as_mut().unwrap().push(other);
1181 config.set_provider_api_key_override(ApiProvider::Deepseek, Some("fixture-key".into()));
1182 let inventory = ModelInventory::from_config(&config);
1183 for (id, context) in [("Preview-fixture", 96000), ("preview-fixture", 128000)] {
1184 let candidate = inventory.candidate(ApiProvider::Deepseek, id).unwrap();
1185 assert!(candidate.user_declared);
1186 assert_eq!(candidate.model, id);
1187 assert_eq!(candidate.context_window, context);
1188 }
1189 assert!(
1190 inventory
1191 .candidate(ApiProvider::Deepseek, "PREVIEW-FIXTURE")
1192 .is_none()
1193 );
1194 }
1195
1196 #[test]
1197 fn ollama_inventory_default_uses_only_the_fresh_exact_endpoint_roster() {
1198 use codewhale_config::catalog::{
1199 CatalogOffering, CatalogRefreshError, CatalogSource, ProviderCatalogDelta,
1200 base_url_fingerprint, now_unix,
1201 };
1202
1203 let _env = crate::test_support::lock_test_env();
1204 let _live = crate::provider_lake::lock_live_snapshot();
1205 let home = tempfile::tempdir().unwrap();
1206 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1207 crate::provider_catalog_live::reset_cache_for_test();
1208 crate::provider_lake::clear_live_snapshot();
1209 let mut config = Config {
1210 provider: Some("ollama".to_string()),
1211 ..Default::default()
1212 };
1213 let endpoint = "http://localhost:11445/v1";
1214 config.provider_config_for_mut(ApiProvider::Ollama).base_url = Some(endpoint.into());
1215 assert_eq!(
1216 provider_default_model(&config, ApiProvider::Ollama),
1217 "unknown"
1218 );
1219 assert!(
1220 ModelInventory::from_config(&config)
1221 .candidates
1222 .iter()
1223 .all(|row| { row.provider != ApiProvider::Ollama || row.model != "unknown" })
1224 );
1225 let fingerprint = base_url_fingerprint(endpoint);
1226 let now = now_unix();
1227 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
1228 ApiProvider::Ollama,
1229 "ollama",
1230 endpoint,
1231 );
1232 crate::provider_catalog_live::record_success_if_current(
1233 &ticket,
1234 ProviderCatalogDelta {
1235 provider: "ollama".into(),
1236 base_url_fingerprint: fingerprint.clone(),
1237 fetched_at: now,
1238 offerings: vec![CatalogOffering {
1239 provider: "ollama".into(),
1240 wire_model_id: "qwen2.5:0.5b".into(),
1241 endpoint_key: "chat".into(),
1242 source: CatalogSource::Live {
1243 base_url_fingerprint: fingerprint.clone(),
1244 fetched_at: now,
1245 },
1246 ..Default::default()
1247 }],
1248 },
1249 );
1250 assert_eq!(
1251 provider_default_model(&config, ApiProvider::Ollama),
1252 "qwen2.5:0.5b"
1253 );
1254 let inventory = ModelInventory::from_config(&config);
1255 assert!(inventory.candidates.iter().any(|row| {
1256 row.provider == ApiProvider::Ollama
1257 && row.model == "qwen2.5:0.5b"
1258 && row.default_for_provider
1259 }));
1260 let mut other = config.clone();
1261 other.provider_config_for_mut(ApiProvider::Ollama).base_url =
1262 Some("http://localhost:11446/v1".into());
1263 assert_eq!(
1264 provider_default_model(&other, ApiProvider::Ollama),
1265 "unknown"
1266 );
1267 crate::provider_catalog_live::record_failure_if_current(
1268 &ticket,
1269 "ollama",
1270 &fingerprint,
1271 CatalogRefreshError::Network,
1272 );
1273 assert_eq!(
1274 provider_default_model(&config, ApiProvider::Ollama),
1275 "unknown"
1276 );
1277 config.set_provider_model_override(ApiProvider::Ollama, Some("chosen:tag".into()));
1278 assert_eq!(
1279 provider_default_model(&config, ApiProvider::Ollama),
1280 "chosen:tag"
1281 );
1282 crate::provider_catalog_live::reset_cache_for_test();
1283 crate::provider_lake::clear_live_snapshot();
1284 }
1285 }
1286
1286 lines RUST