返回 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::{all_catalog_models_for_provider, 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 pub(crate) context_window: u32,
32 /// Known output ceiling, or `None` when this route publishes none. The
33 /// classifier is told "unknown" rather than a fabricated number.
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub(crate) max_output: Option<u32>,
36 pub(crate) thinking_supported: bool,
37 pub(crate) cache_telemetry_supported: bool,
38 pub(crate) auth_source: ModelAuthSource,
39 pub(crate) readiness: crate::provider_readiness::ResolvedProviderReadiness,
40 pub(crate) default_for_provider: bool,
41 pub(crate) tags: Vec<&'static str>,
42 }
43
44 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45 pub(crate) struct ModelInventory {
46 pub(crate) active_provider: ApiProvider,
47 pub(crate) router_provider: ApiProvider,
48 pub(crate) router_model: String,
49 /// Thinking tier for the classifier call (None = off) (#auto.router).
50 pub(crate) router_thinking: Option<String>,
51 /// Whether an explicit legacy `[auto.router]` classifier route is
52 /// configured. Absent configuration means legacy Auto stays local/free —
53 /// holding a provider key never elects a network classifier by itself.
54 pub(crate) router_configured: bool,
55 pub(crate) router_available: bool,
56 /// `[auto] cross_provider = true` opt-in (#4411). When false (the
57 /// default), Auto routing — classifier payload included — is confined to
58 /// `active_provider`. The full candidate list still carries every
59 /// authenticated provider because pickers and explicit `/model` lookups
60 /// legitimately need it; only the Auto paths are scoped.
61 pub(crate) cross_provider_auto: bool,
62 pub(crate) candidates: Vec<ModelRouteCandidate>,
63 }
64
65 impl ModelInventory {
66 pub(crate) fn from_config(config: &Config) -> Self {
67 Self::from_config_with_health(
68 config,
69 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
70 )
71 }
72
73 pub(crate) fn from_config_with_health(
74 config: &Config,
75 health: &crate::provider_readiness::ProviderReadinessSnapshot,
76 ) -> Self {
77 let active_provider = config.api_provider();
78 let mut candidates = Vec::new();
79
80 for provider in ApiProvider::all().iter().copied() {
81 let Some(auth_source) = auth_source_for_provider(config, provider) else {
82 continue;
83 };
84 let default_model = provider_default_model(config, provider);
85 let mut models = Vec::<String>::new();
86 if let Some(model) = configured_model_for_provider(config, provider) {
87 push_model(&mut models, provider, &model);
88 }
89 if provider == active_provider {
90 let active_model = config.default_model();
91 if !active_model.trim().eq_ignore_ascii_case("auto") {
92 push_model(&mut models, provider, &active_model);
93 }
94 }
95 for model in models_for_provider(config, active_provider, provider) {
96 push_model(&mut models, provider, &model);
97 }
98 if models.is_empty() {
99 push_model(&mut models, provider, &default_model);
100 }
101
102 for model in models {
103 let readiness =
104 crate::provider_readiness::resolve_for_model(config, provider, &model, health);
105 let mut capability = provider_capability(provider, &model);
106 if let Ok(route) =
107 crate::route_runtime::resolve_runtime_route(config, provider, Some(&model))
108 {
109 if let Some(context_window) = route.candidate.limits().context_tokens {
110 capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
111 }
112 // A concrete offering maximum is a stronger fact than the
113 // static compatibility matrix — and is the only way a
114 // membership route (no static cap) gets a known ceiling.
115 if let Some(max_output) = route
116 .candidate
117 .limits()
118 .output_tokens
119 .and_then(|tokens| u32::try_from(tokens).ok())
120 .filter(|tokens| *tokens > 0)
121 {
122 capability.max_output = Some(max_output);
123 }
124 // Do not promote bare `k3` into the global capability
125 // catalog. Its thinking trace contract belongs only to
126 // Kimi Code's exact membership-plan route.
127 if crate::config::is_exact_kimi_code_k3_route(
128 provider,
129 &route.candidate.endpoint().base_url,
130 route.candidate.wire_model_id().as_str(),
131 ) {
132 capability.thinking_supported = true;
133 }
134 }
135 let mut tags = Vec::new();
136 if capability.context_window >= 1_000_000 {
137 tags.push("long_context");
138 }
139 if capability.thinking_supported {
140 tags.push("thinking");
141 }
142 if matches!(
143 provider,
144 ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm
145 ) {
146 tags.push("local");
147 }
148 // Unready routes stay visible (annotated) so an operator can
149 // override explicitly, but they are never a silent default.
150 let default_for_provider =
151 readiness.can_attempt() && model.eq_ignore_ascii_case(&default_model);
152 if default_for_provider {
153 tags.push("default");
154 }
155 if !readiness.can_attempt() {
156 tags.push("unready");
157 }
158
159 candidates.push(ModelRouteCandidate {
160 provider,
161 provider_name: provider.as_str(),
162 provider_display_name: provider.display_name(),
163 default_for_provider,
164 model,
165 context_window: capability.context_window,
166 max_output: capability.max_output,
167 thinking_supported: capability.thinking_supported,
168 cache_telemetry_supported: capability.cache_telemetry_supported,
169 auth_source: auth_source.clone(),
170 readiness: readiness.clone(),
171 tags,
172 });
173 }
174 }
175
176 // `[auto.router]` is legacy `model = auto` configuration and stays that
177 // way — it is NOT a Fleet Router. Explicit configuration still works.
178 //
179 // What is gone is the implicit half: merely holding a DeepSeek key used
180 // to silently elect `deepseek-v4-flash` as a network classifier for
181 // every Auto turn, spending a user's tokens on a route they never asked
182 // for and privileging one provider. With no explicit `[auto.router]`,
183 // legacy Auto is now local/free (heuristic-only).
184 let explicit_router = config
185 .auto
186 .as_ref()
187 .and_then(|auto| auto.router.as_ref())
188 .and_then(|router| {
189 let provider = router.provider.as_deref().and_then(ApiProvider::parse)?;
190 let model = router
191 .model
192 .as_deref()
193 .map(str::trim)
194 .filter(|m| !m.is_empty())?;
195 Some((
196 provider,
197 model.to_string(),
198 router
199 .thinking
200 .as_deref()
201 .map(str::trim)
202 .filter(|t| !t.is_empty())
203 .map(str::to_string),
204 ))
205 });
206 let router_configured = explicit_router.is_some();
207 let (router_provider, router_model, router_thinking) = explicit_router
208 // Kept only as an inert display/default label for the router fields;
209 // `router_available` below is what gates any classifier call.
210 .unwrap_or_else(|| (ApiProvider::Deepseek, "deepseek-v4-flash".to_string(), None));
211
212 let cross_provider_auto = config.auto_cross_provider();
213
214 Self {
215 active_provider,
216 router_provider,
217 router_configured,
218 router_available: router_configured && has_api_key_for(config, router_provider),
219 router_model,
220 router_thinking,
221 cross_provider_auto,
222 candidates,
223 }
224 }
225
226 /// Whether Auto routing may select `provider` (#4411).
227 pub(crate) fn auto_scope_allows(&self, provider: ApiProvider) -> bool {
228 self.cross_provider_auto || provider == self.active_provider
229 }
230
231 pub(crate) fn candidate(
232 &self,
233 provider: ApiProvider,
234 model: &str,
235 ) -> Option<&ModelRouteCandidate> {
236 self.candidates.iter().find(|candidate| {
237 candidate.provider == provider && candidate.model.eq_ignore_ascii_case(model.trim())
238 })
239 }
240
241 pub(crate) fn active_default(&self) -> Option<&ModelRouteCandidate> {
242 self.candidates
243 .iter()
244 .find(|candidate| {
245 candidate.provider == self.active_provider && candidate.default_for_provider
246 })
247 .or_else(|| {
248 self.candidates.iter().find(|candidate| {
249 candidate.provider == self.active_provider && candidate.readiness.can_attempt()
250 })
251 })
252 .or_else(|| {
253 // Falling through to another provider is a cross-provider Auto
254 // route (#4411): allowed only under the persisted opt-in. With
255 // it off, an unusable active provider surfaces as "no runnable
256 // candidate" instead of silently borrowing another provider's
257 // credentials.
258 self.cross_provider_auto
259 .then(|| {
260 self.candidates
261 .iter()
262 .find(|candidate| candidate.readiness.can_attempt())
263 })
264 .flatten()
265 })
266 }
267
268 pub(crate) fn router_context_json(&self) -> String {
269 #[derive(Serialize)]
270 struct RouterInventoryContext<'a> {
271 active_provider: ApiProvider,
272 candidates: Vec<RouterCandidateContext<'a>>,
273 }
274
275 #[derive(Serialize)]
276 struct RouterCandidateContext<'a> {
277 provider: ApiProvider,
278 provider_name: &'a str,
279 provider_display_name: &'a str,
280 model: &'a str,
281 context_window: u32,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 max_output: Option<u32>,
284 thinking_supported: bool,
285 cache_telemetry_supported: bool,
286 default_for_provider: bool,
287 tags: &'a [&'static str],
288 }
289
290 // The classifier needs route capabilities, not credentials, endpoint
291 // configuration, or provider error text. Filter to runnable candidates
292 // and project only non-secret routing facts before serializing.
293 //
294 // Scope (#4411): without the persisted `[auto] cross_provider` opt-in,
295 // the payload names only the active provider's routes. Which other
296 // providers a user has credentials for is not something Auto discloses
297 // to a classifier by default.
298 let candidates = self
299 .candidates
300 .iter()
301 .filter(|candidate| {
302 candidate.readiness.can_attempt() && self.auto_scope_allows(candidate.provider)
303 })
304 .map(|candidate| RouterCandidateContext {
305 provider: candidate.provider,
306 provider_name: candidate.provider_name,
307 provider_display_name: candidate.provider_display_name,
308 model: &candidate.model,
309 context_window: candidate.context_window,
310 max_output: candidate.max_output,
311 thinking_supported: candidate.thinking_supported,
312 cache_telemetry_supported: candidate.cache_telemetry_supported,
313 default_for_provider: candidate.default_for_provider,
314 tags: &candidate.tags,
315 })
316 .collect();
317 serde_json::to_string(&RouterInventoryContext {
318 active_provider: self.active_provider,
319 candidates,
320 })
321 .unwrap_or_else(|_| "{}".to_string())
322 }
323 }
324
325 fn push_model(models: &mut Vec<String>, provider: ApiProvider, model: &str) {
326 let Some(model) = normalize_model_name_for_provider(provider, model)
327 .or_else(|| crate::config::normalize_custom_model_id(model))
328 else {
329 return;
330 };
331 if !models
332 .iter()
333 .any(|existing| existing.eq_ignore_ascii_case(&model))
334 {
335 models.push(model);
336 }
337 }
338
339 fn configured_model_for_provider(config: &Config, provider: ApiProvider) -> Option<String> {
340 config
341 .provider_config_for(provider)
342 .and_then(|entry| entry.model.clone())
343 .map(|model| model.trim().to_string())
344 .filter(|model| !model.is_empty())
345 }
346
347 fn provider_default_model(config: &Config, provider: ApiProvider) -> String {
348 if provider == config.api_provider() {
349 let model = config.default_model();
350 if !model.trim().eq_ignore_ascii_case("auto") {
351 return model;
352 }
353 }
354 if provider == ApiProvider::Moonshot
355 && config
356 .provider_config_for(provider)
357 .is_some_and(crate::config::provider_config_uses_kimi_imported_token)
358 {
359 return crate::config::DEFAULT_KIMI_CODE_MODEL.to_string();
360 }
361 all_catalog_models_for_provider(provider)
362 .first()
363 .map(|model| model.as_str())
364 .unwrap_or(match provider {
365 ApiProvider::Ollama => crate::config::DEFAULT_OLLAMA_MODEL,
366 ApiProvider::Sglang => crate::config::DEFAULT_SGLANG_MODEL,
367 ApiProvider::Vllm => crate::config::DEFAULT_VLLM_MODEL,
368 _ => crate::config::DEFAULT_TEXT_MODEL,
369 })
370 .to_string()
371 }
372
373 fn auth_source_for_provider(config: &Config, provider: ApiProvider) -> Option<ModelAuthSource> {
374 let credential_state =
375 crate::provider_readiness::credential_state_for_provider(config, provider);
376 match credential_state {
377 crate::provider_readiness::CredentialState::NoAuth => {
378 return Some(ModelAuthSource::NoAuth);
379 }
380 crate::provider_readiness::CredentialState::Local => {
381 return Some(ModelAuthSource::KeylessLocal);
382 }
383 crate::provider_readiness::CredentialState::ImportedToken => {
384 return Some(ModelAuthSource::ImportedToken);
385 }
386 crate::provider_readiness::CredentialState::MissingKey
387 | crate::provider_readiness::CredentialState::MissingLogin
388 | crate::provider_readiness::CredentialState::ExternalConsent
389 | crate::provider_readiness::CredentialState::Legacy => return None,
390 crate::provider_readiness::CredentialState::Saved => {}
391 }
392
393 if provider == ApiProvider::Custom {
394 let configured = config.provider_config_for(provider)?;
395 if configured
396 .api_key_env
397 .as_deref()
398 .map(str::trim)
399 .filter(|name| !name.is_empty())
400 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
401 {
402 return Some(ModelAuthSource::Env);
403 }
404 return (configured.api_key.as_deref().is_some_and(|value| {
405 crate::config::classify_config_api_key_value(value)
406 == crate::config::ConfigApiKeyValueKind::Literal
407 }) || crate::config::explicit_cli_api_key_override().is_some())
408 .then_some(ModelAuthSource::Config);
409 }
410 if provider_uses_oauth_cli(config, provider) {
411 return Some(ModelAuthSource::OAuthCli);
412 }
413 if config
414 .provider_config_for(provider)
415 .and_then(|entry| entry.api_key_env.as_deref())
416 .map(str::trim)
417 .filter(|name| !name.is_empty())
418 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
419 {
420 return Some(ModelAuthSource::Env);
421 }
422 if !config.should_skip_secret_store_for_provider(provider) && env_has_key_for(provider) {
423 return Some(ModelAuthSource::Env);
424 }
425 Some(ModelAuthSource::Config)
426 }
427
428 fn provider_uses_oauth_cli(config: &Config, provider: ApiProvider) -> bool {
429 if config.provider_uses_custom_endpoint(provider) {
430 return false;
431 }
432 match provider {
433 ApiProvider::OpenaiCodex => true,
434 ApiProvider::Xai => config
435 .provider_config_for(provider)
436 .and_then(|entry| entry.auth_mode.as_deref())
437 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth),
438 _ => false,
439 }
440 }
441
442 fn env_has_key_for(provider: ApiProvider) -> bool {
443 env_keys_for_provider(provider)
444 .iter()
445 .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
446 }
447
448 fn env_keys_for_provider(provider: ApiProvider) -> &'static [&'static str] {
449 provider.env_vars()
450 }
451
452 #[cfg(test)]
453 mod tests {
454 use super::*;
455
456 #[test]
457 fn inventory_env_keys_follow_provider_metadata() {
458 for provider in ApiProvider::all() {
459 assert_eq!(env_keys_for_provider(*provider), provider.env_vars());
460 }
461 }
462
463 #[test]
464 fn inventory_includes_only_usable_authenticated_providers() {
465 let _env_lock = crate::test_support::lock_test_env();
466 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
467 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
468 let _minimax = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
469 let config = Config {
470 provider: Some("zai".to_string()),
471 default_text_model: Some("deepseek-v4-pro".to_string()),
472 ..Default::default()
473 };
474
475 let inventory = ModelInventory::from_config(&config);
476
477 // A DeepSeek key alone no longer elects a network classifier: with no
478 // explicit `[auto.router]`, legacy Auto stays local/free.
479 assert!(!inventory.router_configured);
480 assert!(!inventory.router_available);
481 assert!(
482 inventory
483 .candidate(ApiProvider::Zai, crate::config::ZAI_GLM_5_2_MODEL)
484 .is_some()
485 );
486 assert!(
487 inventory
488 .candidates
489 .iter()
490 .all(|candidate| candidate.provider != ApiProvider::Minimax)
491 );
492 }
493
494 #[test]
495 fn inventory_marks_local_providers_keyless() {
496 let _env_lock = crate::test_support::lock_test_env();
497 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
498 let config = Config::default();
499
500 let inventory = ModelInventory::from_config(&config);
501
502 assert!(
503 inventory
504 .candidates
505 .iter()
506 .any(|candidate| candidate.provider == ApiProvider::Ollama
507 && candidate.auth_source == ModelAuthSource::KeylessLocal)
508 );
509 }
510
511 #[test]
512 fn inventory_never_admits_kimi_cli_oauth_import() {
513 let _env_lock = crate::test_support::lock_test_env();
514 let temp = tempfile::tempdir().expect("Kimi import fixture root");
515 let kimi_home = temp.path().join("kimi-code");
516 std::fs::create_dir_all(kimi_home.join("credentials")).expect("Kimi credential directory");
517 let expires_at = std::time::SystemTime::now()
518 .duration_since(std::time::UNIX_EPOCH)
519 .expect("clock after epoch")
520 .as_secs_f64()
521 + 3600.0;
522 let credential_path = kimi_home.join("credentials/kimi-code.json");
523 let credential_raw = serde_json::json!({
524 "access_token": "unexpired-user-owned-token",
525 "refresh_token": "must-not-be-used",
526 "expires_at": expires_at,
527 })
528 .to_string();
529 std::fs::write(&credential_path, &credential_raw).expect("write Kimi import fixture");
530 let _kimi_home = crate::test_support::EnvVarGuard::set(
531 "KIMI_CODE_HOME",
532 kimi_home.to_str().expect("utf8 path"),
533 );
534 let config = Config {
535 provider: Some("moonshot".to_string()),
536 providers: Some(crate::config::ProvidersConfig {
537 moonshot: crate::config::ProviderConfig {
538 auth_mode: Some("kimi_oauth".to_string()),
539 ..Default::default()
540 },
541 ..Default::default()
542 }),
543 ..Default::default()
544 };
545
546 let inventory = ModelInventory::from_config(&config);
547 assert!(
548 inventory
549 .candidates
550 .iter()
551 .all(|candidate| candidate.provider != ApiProvider::Moonshot),
552 "unsupported Kimi CLI OAuth must not enter the routing inventory"
553 );
554 assert_eq!(
555 std::fs::read_to_string(credential_path).expect("Kimi file remains untouched"),
556 credential_raw
557 );
558 }
559
560 #[test]
561 fn inventory_uses_kimi_code_k3_route_context_not_generic_fallback() {
562 let config = Config {
563 provider: Some("moonshot".to_string()),
564 providers: Some(crate::config::ProvidersConfig {
565 moonshot: crate::config::ProviderConfig {
566 api_key: Some("test-kimi-key".to_string()),
567 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
568 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
569 ..Default::default()
570 },
571 ..Default::default()
572 }),
573 ..Default::default()
574 };
575
576 let inventory = ModelInventory::from_config(&config);
577 let candidate = inventory
578 .candidate(ApiProvider::Moonshot, crate::config::KIMI_CODE_K3_MODEL)
579 .expect("configured Kimi Code K3 route");
580
581 assert_eq!(candidate.context_window, 262_144);
582 assert!(candidate.thinking_supported);
583 assert!(candidate.tags.contains(&"thinking"));
584 assert!(!candidate.tags.contains(&"long_context"));
585 }
586
587 #[test]
588 fn inventory_includes_custom_api_key_env_route() {
589 let _env_lock = crate::test_support::lock_test_env();
590 let _custom_key = crate::test_support::EnvVarGuard::set("ACME_CUSTOM_KEY", "custom-key");
591 let config = Config {
592 provider: Some("acme".to_string()),
593 providers: Some(crate::config::ProvidersConfig {
594 custom: std::collections::HashMap::from([(
595 "acme".to_string(),
596 crate::config::ProviderConfig {
597 kind: Some("openai-compatible".to_string()),
598 base_url: Some("https://api.acme.test/v1".to_string()),
599 model: Some("acme-coder".to_string()),
600 api_key_env: Some("ACME_CUSTOM_KEY".to_string()),
601 ..Default::default()
602 },
603 )]),
604 ..Default::default()
605 }),
606 ..Default::default()
607 };
608
609 let inventory = ModelInventory::from_config(&config);
610 assert!(
611 inventory
612 .candidates
613 .iter()
614 .any(|candidate| candidate.provider == ApiProvider::Custom
615 && candidate.model == "acme-coder"
616 && candidate.auth_source == ModelAuthSource::Env)
617 );
618 }
619
620 #[test]
621 fn inventory_ignores_unresolved_command_and_secret_auth_metadata() {
622 let _env_lock = crate::test_support::lock_test_env();
623 let temp = tempfile::tempdir().expect("isolated credential home");
624 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
625 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
626 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
627 let _openai = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
628 let _xai = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
629 let mut providers = crate::config::ProvidersConfig::default();
630 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
631 source: codewhale_config::AuthSourceKind::Command,
632 command: vec!["secret-tool".to_string(), "lookup".to_string()],
633 timeout_ms: Some(2000),
634 secret_id: None,
635 });
636 providers.xai.auth = Some(codewhale_config::ProviderAuthSourceToml {
637 source: codewhale_config::AuthSourceKind::Secret,
638 command: Vec::new(),
639 timeout_ms: None,
640 secret_id: Some("codewhale/xai".to_string()),
641 });
642 let config = Config {
643 provider: Some("openai".to_string()),
644 providers: Some(providers),
645 ..Default::default()
646 };
647
648 let inventory = ModelInventory::from_config(&config);
649 assert!(inventory.candidates.iter().all(|candidate| !matches!(
650 candidate.provider,
651 ApiProvider::Openai | ApiProvider::Xai
652 )));
653 }
654
655 #[test]
656 fn auto_router_config_overrides_default_classifier_route() {
657 let config = Config {
658 auto: Some(crate::config::AutoConfig {
659 cost_saving: None,
660 cross_provider: None,
661 router: Some(crate::config::AutoRouterConfig {
662 provider: Some("zai".to_string()),
663 model: Some("glm-5-turbo".to_string()),
664 thinking: Some("low".to_string()),
665 }),
666 }),
667 ..Default::default()
668 };
669
670 let inventory = ModelInventory::from_config(&config);
671 assert!(inventory.router_configured);
672 assert_eq!(inventory.router_provider, ApiProvider::Zai);
673 assert_eq!(inventory.router_model, "glm-5-turbo");
674 assert_eq!(inventory.router_thinking.as_deref(), Some("low"));
675 }
676
677 /// A DeepSeek key must never, on its own, turn on a network classifier.
678 /// `[auto.router]` stays legacy `model = auto` configuration; absent it,
679 /// legacy Auto is local/free.
680 #[test]
681 fn a_deepseek_key_alone_never_elects_an_implicit_flash_classifier() {
682 let _env_lock = crate::test_support::lock_test_env();
683 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
684 let config = Config {
685 provider: Some("deepseek".to_string()),
686 ..Default::default()
687 };
688
689 let inventory = ModelInventory::from_config(&config);
690
691 assert!(
692 !inventory.router_configured,
693 "no [auto.router] means no configured classifier"
694 );
695 assert!(
696 !inventory.router_available,
697 "holding a DeepSeek key must not silently select deepseek-v4-flash as a classifier"
698 );
699 }
700
701 #[test]
702 fn an_explicit_legacy_auto_router_still_works_when_its_key_is_present() {
703 let _env_lock = crate::test_support::lock_test_env();
704 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
705 let config = Config {
706 auto: Some(crate::config::AutoConfig {
707 cost_saving: None,
708 router: Some(crate::config::AutoRouterConfig {
709 provider: Some("zai".to_string()),
710 model: Some("glm-5-turbo".to_string()),
711 thinking: None,
712 }),
713 cross_provider: None,
714 }),
715 ..Default::default()
716 };
717
718 let inventory = ModelInventory::from_config(&config);
719
720 assert!(inventory.router_configured);
721 assert!(inventory.router_available);
722 assert_eq!(inventory.router_model, "glm-5-turbo");
723 }
724
725 #[test]
726 fn inventory_marks_explicit_no_auth_separately_from_keyless_local() {
727 let mut providers = crate::config::ProvidersConfig::default();
728 providers.vllm.auth_mode = Some("none".to_string());
729 providers.vllm.model = Some("local-model".to_string());
730 let config = Config {
731 provider: Some("vllm".to_string()),
732 providers: Some(providers),
733 ..Default::default()
734 };
735
736 let inventory = ModelInventory::from_config(&config);
737 let candidate = inventory
738 .candidates
739 .iter()
740 .find(|candidate| {
741 candidate.provider == ApiProvider::Vllm && candidate.model == "local-model"
742 })
743 .expect("vLLM no-auth candidate");
744
745 assert_eq!(candidate.auth_source, ModelAuthSource::NoAuth);
746 assert_eq!(
747 candidate.readiness,
748 crate::provider_readiness::ResolvedProviderReadiness::NoAuthUnchecked
749 );
750 }
751
752 #[test]
753 fn unready_candidates_are_never_provider_defaults() {
754 use crate::provider_readiness::ResolvedProviderReadiness;
755
756 let candidate = ModelRouteCandidate {
757 provider: ApiProvider::Openai,
758 provider_name: "openai",
759 provider_display_name: "OpenAI",
760 model: "gpt-5.5".to_string(),
761 context_window: 128_000,
762 max_output: Some(16_384),
763 thinking_supported: true,
764 cache_telemetry_supported: false,
765 auth_source: ModelAuthSource::Config,
766 readiness: ResolvedProviderReadiness::MissingLogin,
767 default_for_provider: false,
768 tags: vec!["unready"],
769 };
770 assert!(!candidate.readiness.can_attempt());
771 assert!(!candidate.default_for_provider);
772 assert!(candidate.tags.contains(&"unready"));
773 }
774
775 #[test]
776 fn active_default_never_falls_back_to_unready_candidate() {
777 let inventory = ModelInventory {
778 active_provider: ApiProvider::Openai,
779 router_provider: ApiProvider::Deepseek,
780 router_model: "deepseek-v4-flash".to_string(),
781 router_thinking: None,
782 router_configured: false,
783 router_available: false,
784 cross_provider_auto: false,
785 candidates: vec![ModelRouteCandidate {
786 provider: ApiProvider::Openai,
787 provider_name: "openai",
788 provider_display_name: "OpenAI",
789 model: "unsupported-model".to_string(),
790 context_window: 1,
791 max_output: Some(1),
792 thinking_supported: false,
793 cache_telemetry_supported: false,
794 auth_source: ModelAuthSource::Config,
795 readiness: crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
796 default_for_provider: false,
797 tags: vec!["unready"],
798 }],
799 };
800
801 assert!(inventory.active_default().is_none());
802 }
803
804 #[test]
805 fn router_context_is_runnable_and_redacts_auth_and_failure_details() {
806 let _env_lock = crate::test_support::lock_test_env();
807 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
808 let mut inventory = ModelInventory::from_config(&Config::default());
809 let candidate = inventory
810 .candidates
811 .iter_mut()
812 .find(|candidate| candidate.provider == ApiProvider::Deepseek)
813 .expect("DeepSeek inventory candidate");
814 candidate.readiness =
815 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed {
816 category: crate::error_taxonomy::ErrorCategory::Authentication,
817 message: "Bearer super-secret-router-token".to_string(),
818 };
819 inventory.candidates.push(ModelRouteCandidate {
820 provider: ApiProvider::Openai,
821 provider_name: "openai",
822 provider_display_name: "OpenAI",
823 model: "unsupported-model".to_string(),
824 context_window: 1,
825 max_output: Some(1),
826 thinking_supported: false,
827 cache_telemetry_supported: false,
828 auth_source: ModelAuthSource::Config,
829 readiness: crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
830 default_for_provider: false,
831 tags: vec!["unready"],
832 });
833
834 let json = inventory.router_context_json();
835
836 assert!(json.contains("deepseek-v4"));
837 assert!(!json.contains("super-secret-router-token"));
838 assert!(!json.contains("auth_source"));
839 assert!(!json.contains("unsupported-model"));
840 }
841
842 #[test]
843 fn router_context_names_only_the_active_provider_by_default() {
844 // #4411: a Z.ai session with a DeepSeek key in the environment must
845 // not disclose the DeepSeek routes — or the fact that a DeepSeek
846 // credential exists — to the classifier.
847 let _env_lock = crate::test_support::lock_test_env();
848 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
849 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
850 let config = Config {
851 provider: Some("zai".to_string()),
852 ..Default::default()
853 };
854
855 let inventory = ModelInventory::from_config(&config);
856 assert!(
857 inventory
858 .candidates
859 .iter()
860 .any(|candidate| candidate.provider == ApiProvider::Deepseek),
861 "the full inventory still knows about DeepSeek for pickers/explicit routes"
862 );
863
864 let json = inventory.router_context_json();
865 let payload: serde_json::Value =
866 serde_json::from_str(&json).expect("router context is JSON");
867 let providers: Vec<&str> = payload["candidates"]
868 .as_array()
869 .expect("candidate array")
870 .iter()
871 .map(|candidate| candidate["provider_name"].as_str().expect("provider name"))
872 .collect();
873
874 assert!(!providers.is_empty(), "active provider routes must remain");
875 assert!(
876 providers.iter().all(|provider| *provider == "zai"),
877 "classifier payload leaked another provider: {json}"
878 );
879 assert!(!json.contains("deepseek"), "{json}");
880 }
881
882 #[test]
883 fn router_context_includes_other_providers_under_persisted_opt_in() {
884 let _env_lock = crate::test_support::lock_test_env();
885 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
886 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
887 let config = Config {
888 provider: Some("zai".to_string()),
889 auto: Some(crate::config::AutoConfig {
890 cost_saving: None,
891 cross_provider: Some(true),
892 router: None,
893 }),
894 ..Default::default()
895 };
896
897 let json = ModelInventory::from_config(&config).router_context_json();
898
899 assert!(json.contains("\"zai\""), "{json}");
900 assert!(json.contains("deepseek"), "{json}");
901 }
902
903 #[test]
904 fn implicit_deepseek_classifier_is_out_of_scope_for_another_active_provider() {
905 // #4411: the default classifier route is DeepSeek flash. Calling it
906 // from a Z.ai session would send the turn's prompt to a second
907 // provider, so it stays unavailable without an explicit opt-in.
908 let _env_lock = crate::test_support::lock_test_env();
909 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
910 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
911 let zai = Config {
912 provider: Some("zai".to_string()),
913 ..Default::default()
914 };
915 assert!(!ModelInventory::from_config(&zai).router_available);
916
917 // `cross_provider = true` widens which candidates Auto may pick; it is
918 // NOT a classifier election. With the implicit DeepSeek-flash default
919 // removed, no network classifier runs without an explicit
920 // `[auto.router]` route — a scope opt-in alone stays local/free.
921 let opted_in = Config {
922 auto: Some(crate::config::AutoConfig {
923 cost_saving: None,
924 cross_provider: Some(true),
925 router: None,
926 }),
927 ..zai.clone()
928 };
929 let widened = ModelInventory::from_config(&opted_in);
930 assert!(!widened.router_available);
931 assert!(widened.auto_scope_allows(ApiProvider::Deepseek));
932
933 // An explicitly configured `[auto.router]` is itself a persisted
934 // opt-in for that classifier route.
935 let explicit_router = Config {
936 auto: Some(crate::config::AutoConfig {
937 cost_saving: None,
938 cross_provider: None,
939 router: Some(crate::config::AutoRouterConfig {
940 provider: Some("deepseek".to_string()),
941 model: Some("deepseek-v4-flash".to_string()),
942 thinking: None,
943 }),
944 }),
945 ..zai.clone()
946 };
947 assert!(ModelInventory::from_config(&explicit_router).router_available);
948
949 // A DeepSeek session gets no free classifier either: with the
950 // implicit flash default removed, only an explicit `[auto.router]`
951 // elects a network classifier, active provider or not.
952 let deepseek = Config {
953 provider: Some("deepseek".to_string()),
954 ..Default::default()
955 };
956 assert!(!ModelInventory::from_config(&deepseek).router_available);
957 }
958 }
959
959 lines RUST