返回 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::client::system_one::DecisionRouterRoute;
10 use crate::config::{
11 AutoRouterKind, Config, ProviderIdentity, ProviderKind, has_api_key_for,
12 normalize_model_name_for_provider, provider_capability,
13 };
14 use crate::provider_lake::models_for_provider;
15
16 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17 #[serde(rename_all = "snake_case")]
18 pub(crate) enum ModelAuthSource {
19 Config,
20 Env,
21 OAuthCli,
22 ImportedToken,
23 NoAuth,
24 KeylessLocal,
25 }
26
27 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28 pub(crate) struct ModelRouteCandidate {
29 #[serde(rename = "provider")]
30 pub(crate) provider_tag: String,
31 #[serde(skip)]
32 pub(crate) provider: ProviderKind,
33 #[serde(skip)]
34 pub(crate) identity: ProviderIdentity,
35 pub(crate) provider_name: String,
36 pub(crate) provider_display_name: String,
37 pub(crate) model: String,
38 /// Explicit declarations keep case-sensitive wire identity; bundled aliases
39 /// retain the existing case-insensitive convenience lookup.
40 #[serde(skip_serializing_if = "std::ops::Not::not")]
41 pub(crate) user_declared: bool,
42 pub(crate) context_window: u32,
43 /// The context window came from the legacy capability fallback (an `_Nk`
44 /// name-suffix parse or a vendor-family heuristic), not a route fact
45 /// (#5441). Serialized only when true so existing payloads stay stable.
46 #[serde(skip_serializing_if = "std::ops::Not::not")]
47 pub(crate) context_window_unverified: bool,
48 /// Known output ceiling, or `None` when this route publishes none. The
49 /// classifier is told "unknown" rather than a fabricated number.
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub(crate) max_output: Option<u32>,
52 pub(crate) thinking_supported: bool,
53 pub(crate) cache_telemetry_supported: bool,
54 pub(crate) auth_source: ModelAuthSource,
55 pub(crate) readiness: crate::provider_readiness::ResolvedProviderReadiness,
56 pub(crate) default_for_provider: bool,
57 pub(crate) tags: Vec<&'static str>,
58 }
59
60 /// Why a declared `[auto.router]` cannot run.
61 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62 #[serde(rename_all = "snake_case")]
63 pub(crate) enum AutoRouterSetupIssue {
64 /// `kind` is neither `"chat"` nor `"decision"`.
65 UnknownKind,
66 /// `kind = "decision"` names a provider that serves no decision API.
67 UnsupportedDecisionProvider,
68 /// `provider` or `model` is missing (or the chat provider is unknown).
69 Incomplete,
70 /// The route is complete but its credential is missing.
71 MissingKey,
72 /// `thinking` is not a reasoning tier; the classifier call would carry
73 /// an effort the provider rejects or silently reinterprets.
74 InvalidThinking,
75 /// `model` is not one the chat router's provider serves (checked where
76 /// the provider's model namespace is known; pass-through providers such
77 /// as OpenRouter or a custom endpoint are validated upstream).
78 InvalidModel,
79 }
80
81 impl AutoRouterSetupIssue {
82 #[must_use]
83 pub(crate) fn label(self) -> &'static str {
84 match self {
85 Self::UnknownKind => "unknown [auto.router] kind (expected \"chat\" or \"decision\")",
86 Self::UnsupportedDecisionProvider => {
87 "decision routers are served by OpenRouter or TypeSafe"
88 }
89 Self::Incomplete => "[auto.router] needs a known provider and a model",
90 Self::MissingKey => "no API key for the router route",
91 Self::InvalidThinking => {
92 "[auto.router] thinking must be auto, off, minimal, low, medium, high, xhigh, ultra, or max"
93 }
94 Self::InvalidModel => "[auto.router] model is not served by the router provider",
95 }
96 }
97 }
98
99 /// Probability or confidence (0..=1) as basis points, so receipts and the
100 /// inventory stay `Eq` and never re-render a float.
101 #[must_use]
102 pub(crate) fn probability_bp(value: f64) -> u16 {
103 if !value.is_finite() {
104 return 0;
105 }
106 // Bounded to 0..=10000 before the cast.
107 (value.clamp(0.0, 1.0) * 10_000.0).round() as u16
108 }
109
110 #[derive(Debug, Clone, PartialEq, Eq)]
111 pub(crate) struct ModelInventory {
112 pub(crate) active_provider: ProviderKind,
113 pub(crate) active_identity: ProviderIdentity,
114 pub(crate) router_provider: ProviderKind,
115 pub(crate) router_identity: Option<ProviderIdentity>,
116 pub(crate) router_model: String,
117 /// Thinking tier for the classifier call (None = off) (#auto.router).
118 pub(crate) router_thinking: Option<String>,
119 /// Classifier call timeout in seconds (default 4; clamped at config load).
120 pub(crate) router_timeout_secs: u64,
121 /// Whether an explicit legacy `[auto.router]` classifier route is
122 /// configured. Absent configuration means legacy Auto stays local/free —
123 /// holding a provider key never elects a network classifier by itself.
124 pub(crate) router_configured: bool,
125 pub(crate) router_available: bool,
126 /// `[auto.router] kind` (#6525); an unknown kind leaves the router
127 /// unconfigured and sets [`Self::router_setup_issue`].
128 pub(crate) router_kind: AutoRouterKind,
129 /// Endpoint for a decision router (`None` for chat routers).
130 pub(crate) router_decision_route: Option<DecisionRouterRoute>,
131 /// Decision-router endpoint override (TypeSafe only).
132 pub(crate) router_base_url: Option<String>,
133 /// Decision-router confidence floor in basis points (0..=10000).
134 pub(crate) router_min_confidence_bp: u16,
135 /// Why a declared `[auto.router]` cannot run. `None` when no router is
136 /// declared or the router is available. A declared-but-unusable router is
137 /// shown as failing, never silently ignored.
138 pub(crate) router_setup_issue: Option<AutoRouterSetupIssue>,
139 /// `[auto] cross_provider = true` opt-in (#4411). When false (the
140 /// default), Auto routing — classifier payload included — is confined to
141 /// `active_provider`. The full candidate list still carries every
142 /// authenticated provider because pickers and explicit `/model` lookups
143 /// legitimately need it; only the Auto paths are scoped.
144 pub(crate) cross_provider_auto: bool,
145 pub(crate) candidates: Vec<ModelRouteCandidate>,
146 }
147
148 impl Serialize for ModelInventory {
149 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
150 use serde::ser::SerializeStruct;
151 let active = codewhale_config::descriptors::tui_wire_tag_for_route(
152 self.active_provider,
153 self.active_identity.key.as_str(),
154 )
155 .ok_or_else(|| serde::ser::Error::custom("contradictory active inventory identity"))?;
156 let router = self
157 .router_identity
158 .as_ref()
159 .map_or_else(
160 || {
161 Some(
162 codewhale_config::descriptors::compatibility_for_kind(self.router_provider)
163 .tui_wire_tag,
164 )
165 },
166 |identity| {
167 codewhale_config::descriptors::tui_wire_tag_for_route(
168 self.router_provider,
169 identity.key.as_str(),
170 )
171 },
172 )
173 .ok_or_else(|| serde::ser::Error::custom("contradictory router inventory identity"))?;
174 let mut state = serializer.serialize_struct("ModelInventory", 14)?;
175 state.serialize_field("active_provider", active)?;
176 state.serialize_field("router_provider", router)?;
177 state.serialize_field("router_model", &self.router_model)?;
178 state.serialize_field("router_thinking", &self.router_thinking)?;
179 state.serialize_field("router_timeout_secs", &self.router_timeout_secs)?;
180 state.serialize_field("router_configured", &self.router_configured)?;
181 state.serialize_field("router_available", &self.router_available)?;
182 state.serialize_field("router_kind", &self.router_kind)?;
183 state.serialize_field("router_decision_route", &self.router_decision_route)?;
184 state.serialize_field("router_base_url", &self.router_base_url)?;
185 state.serialize_field("router_min_confidence_bp", &self.router_min_confidence_bp)?;
186 state.serialize_field("router_setup_issue", &self.router_setup_issue)?;
187 state.serialize_field("cross_provider_auto", &self.cross_provider_auto)?;
188 state.serialize_field("candidates", &self.candidates)?;
189 state.end()
190 }
191 }
192
193 impl ModelInventory {
194 pub(crate) fn from_config(config: &Config) -> Result<Self, String> {
195 Self::from_config_with_health(
196 config,
197 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
198 )
199 }
200
201 pub(crate) fn from_config_with_health(
202 config: &Config,
203 health: &crate::provider_readiness::ProviderReadinessSnapshot,
204 ) -> Result<Self, String> {
205 let active_identity = config.active_provider_identity()?;
206 let active_provider = active_identity.provider;
207 let mut candidates = Vec::new();
208
209 for identity in config.provider_identities() {
210 let provider = identity.provider;
211 let Some(auth_source) = auth_source_for_provider(config, &identity) else {
212 continue;
213 };
214 let default_model = provider_default_model(config, &identity);
215 let mut models = Vec::<String>::new();
216 if let Some(model) = configured_model_for_provider(config, &identity) {
217 push_model(&mut models, provider, &model);
218 }
219 if active_identity == identity {
220 let active_model = config.default_model();
221 if !active_model.trim().eq_ignore_ascii_case("auto") {
222 push_model(&mut models, provider, &active_model);
223 }
224 }
225 for model in models_for_provider(config, &identity) {
226 push_model(&mut models, provider, &model);
227 }
228 for declaration in config.custom_models.as_deref().unwrap_or_default() {
229 if crate::provider_lake::configured_model_for_route(
230 config,
231 provider,
232 identity.key.as_str(),
233 &config.base_url_for_route(&identity),
234 &declaration.id,
235 )
236 .is_some()
237 && !models.contains(&declaration.id)
238 {
239 models.push(declaration.id.clone());
240 }
241 }
242 if models.is_empty() {
243 push_model(&mut models, provider, &default_model);
244 }
245
246 for model in models {
247 let readiness =
248 crate::provider_readiness::resolve_for_model(config, &identity, &model, health);
249 let mut capability = provider_capability(provider, &model);
250 let mut user_declared = false;
251 // #5239/#5441: a candidate whose window came from the legacy
252 // capability fallback (a `_Nk` name-suffix parse or a
253 // vendor-family heuristic) carries the number *and* the fact
254 // that nobody verified it — the auto-router must not read a
255 // guessed window as a route capability.
256 let mut context_window_unverified =
257 codewhale_config::catalog::reviewed::intrinsic_model(&model)
258 .and_then(|row| row.context_window)
259 .is_none();
260 if let Ok(route) = crate::route_runtime::resolve_runtime_route_for_identity(
261 config,
262 &identity,
263 Some(&model),
264 ) {
265 if let Some(context_window) = route.candidate.limits().context_tokens {
266 capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
267 context_window_unverified = !route.context_window.source.is_verified();
268 }
269 // A concrete offering maximum is a stronger fact than the
270 // static compatibility matrix — and is the only way a
271 // membership route (no static cap) gets a known ceiling.
272 if let Some(max_output) = route
273 .candidate
274 .limits()
275 .output_tokens
276 .and_then(|tokens| u32::try_from(tokens).ok())
277 .filter(|tokens| *tokens > 0)
278 {
279 capability.max_output = Some(max_output);
280 }
281 user_declared = route
282 .candidate
283 .applied_limit_overrides()
284 .iter()
285 .any(|entry| {
286 entry.source
287 == codewhale_config::route::OverrideSource::UserModelMetadata
288 });
289 if user_declared {
290 context_window_unverified = !route.context_window.source.is_verified();
291 capability.context_window = route.context_window.tokens;
292 capability.max_output = route
293 .candidate
294 .limits()
295 .output_tokens
296 .and_then(|value| u32::try_from(value).ok());
297 capability.thinking_supported = route.candidate.capabilities().reasoning
298 == codewhale_config::route::CapabilityState::Supported;
299 }
300 // Do not promote bare `k3` into the global capability
301 // catalog. Its thinking trace contract belongs only to
302 // Kimi Code's exact membership-plan route.
303 if !user_declared
304 && crate::config::is_exact_kimi_code_k3_route(
305 provider,
306 &route.candidate.endpoint().base_url,
307 route.candidate.wire_model_id().as_str(),
308 )
309 {
310 capability.thinking_supported = true;
311 }
312 }
313 let mut tags = Vec::new();
314 if capability.context_window >= 1_000_000 {
315 tags.push("long_context");
316 }
317 if capability.thinking_supported {
318 tags.push("thinking");
319 }
320 if matches!(
321 provider,
322 ProviderKind::Ollama | ProviderKind::Sglang | ProviderKind::Vllm
323 ) {
324 tags.push("local");
325 }
326 // Unready routes stay visible (annotated) so an operator can
327 // override explicitly, but they are never a silent default.
328 let default_for_provider = readiness.can_attempt()
329 && (model == default_model
330 || (!user_declared && model.eq_ignore_ascii_case(&default_model)));
331 if default_for_provider {
332 tags.push("default");
333 }
334 if !readiness.can_attempt() {
335 tags.push("unready");
336 }
337
338 candidates.push(ModelRouteCandidate {
339 provider,
340 provider_tag: codewhale_config::descriptors::tui_wire_tag_for_route(
341 provider,
342 identity.key.as_str(),
343 )
344 .expect("admitted identity has a wire projection")
345 .to_string(),
346 identity: identity.clone(),
347 provider_name: identity.key.to_string(),
348 provider_display_name: identity.compatibility().map_or_else(
349 || format!("{} (custom)", identity.key),
350 |row| row.label.to_string(),
351 ),
352 default_for_provider,
353 model,
354 user_declared,
355 context_window: capability.context_window,
356 context_window_unverified,
357 max_output: capability.max_output,
358 thinking_supported: capability.thinking_supported,
359 cache_telemetry_supported: capability.cache_telemetry_supported,
360 auth_source: auth_source.clone(),
361 readiness: readiness.clone(),
362 tags,
363 });
364 }
365 }
366
367 // `[auto.router]` is legacy `model = auto` configuration and stays that
368 // way — it is NOT a Fleet Router. Explicit configuration still works.
369 //
370 // What is gone is the implicit half: merely holding a DeepSeek key used
371 // to silently elect `deepseek-v4-flash` as a network classifier for
372 // every Auto turn, spending a user's tokens on a route they never asked
373 // for and privileging one provider. With no explicit `[auto.router]`,
374 // legacy Auto is now local/free (heuristic-only).
375 let router_table = config.auto.as_ref().and_then(|auto| auto.router.as_ref());
376 // Any populated key declares a router: a table holding only
377 // `timeout_secs` or `base_url` is a malformed router to diagnose, not
378 // an absent one to skip silently.
379 let router_declared = router_table.is_some_and(|router| {
380 router.provider.is_some()
381 || router.model.is_some()
382 || router.kind.is_some()
383 || router.thinking.is_some()
384 || router.timeout_secs.is_some()
385 || router.min_confidence.is_some()
386 || router.base_url.is_some()
387 });
388 let router_kind = AutoRouterKind::parse(router_table.and_then(|r| r.kind.as_deref()));
389 let router_model_setting = router_table
390 .and_then(|router| router.model.as_deref())
391 .map(str::trim)
392 .filter(|model| !model.is_empty());
393 let router_provider_setting = router_table
394 .and_then(|router| router.provider.as_deref())
395 .map(str::trim)
396 .filter(|provider| !provider.is_empty());
397 let mut router_setup_issue = None;
398 let mut router_decision_route = None;
399 let explicit_router = match router_kind {
400 None => {
401 router_setup_issue = Some(AutoRouterSetupIssue::UnknownKind);
402 None
403 }
404 Some(AutoRouterKind::Chat) => {
405 let thinking = router_table
406 .and_then(|router| router.thinking.as_deref())
407 .map(str::trim)
408 .filter(|t| !t.is_empty());
409 // A typo here would otherwise ride every classifier request
410 // as an effort string the provider rejects, failing Auto back
411 // to the local fallback on every turn.
412 if thinking.is_some_and(|thinking| {
413 crate::reasoning_preference::ReasoningEffort::parse_strict(thinking).is_err()
414 }) {
415 router_setup_issue = Some(AutoRouterSetupIssue::InvalidThinking);
416 None
417 } else {
418 let route = router_provider_setting
419 .and_then(|name| config.resolve_provider_pin_identity(name).ok())
420 .zip(router_model_setting);
421 // Same check a session route gets: a model the provider
422 // cannot serve would 404 every classifier call and fall
423 // back to local routing on every turn.
424 if route.as_ref().is_some_and(|(identity, model)| {
425 crate::config::validate_route(identity.provider, model).is_err()
426 }) {
427 router_setup_issue = Some(AutoRouterSetupIssue::InvalidModel);
428 None
429 } else {
430 route.map(|(identity, model)| {
431 (
432 identity.provider,
433 Some(identity),
434 model.to_string(),
435 thinking.map(str::to_string),
436 )
437 })
438 }
439 }
440 }
441 Some(AutoRouterKind::Decision) => {
442 match router_provider_setting.map(DecisionRouterRoute::parse) {
443 Some(None) => {
444 router_setup_issue =
445 Some(AutoRouterSetupIssue::UnsupportedDecisionProvider);
446 None
447 }
448 Some(Some(route)) => router_model_setting.map(|model| {
449 router_decision_route = Some(route);
450 // A decision model has no reasoning knob, so the
451 // configured `thinking` is ignored. TypeSafe is not a
452 // chat provider; `router_provider` is only a label
453 // there, and `router_decision_route` is authoritative.
454 (
455 ProviderKind::Openrouter,
456 config
457 .builtin_provider_identity(ProviderKind::Openrouter)
458 .ok(),
459 model.to_string(),
460 None,
461 )
462 }),
463 None => None,
464 }
465 }
466 };
467 let router_configured = explicit_router.is_some();
468 let (router_provider, router_identity, router_model, router_thinking) = explicit_router
469 // Kept only as an inert display/default label for the router fields;
470 // `router_available` below is what gates any classifier call.
471 .unwrap_or_else(|| {
472 (
473 ProviderKind::Deepseek,
474 None,
475 "deepseek-v4-flash".to_string(),
476 None,
477 )
478 });
479 let router_available = router_configured
480 && match router_decision_route {
481 Some(route) => route.has_key(config),
482 None => router_identity
483 .as_ref()
484 .is_some_and(|identity| has_api_key_for(config, identity)),
485 };
486 if router_declared && router_setup_issue.is_none() && !router_available {
487 router_setup_issue = Some(if router_configured {
488 AutoRouterSetupIssue::MissingKey
489 } else {
490 AutoRouterSetupIssue::Incomplete
491 });
492 }
493
494 let cross_provider_auto = config.auto_cross_provider();
495 let router_timeout_secs = config.auto_router_timeout_secs();
496 let router_min_confidence_bp = probability_bp(config.auto_router_min_confidence());
497
498 Ok(Self {
499 active_provider,
500 active_identity,
501 router_provider,
502 router_identity,
503 router_configured,
504 router_available,
505 router_model,
506 router_thinking,
507 router_timeout_secs,
508 router_kind: router_kind.unwrap_or(AutoRouterKind::Chat),
509 router_decision_route,
510 router_base_url: router_table
511 .and_then(|router| router.base_url.as_deref())
512 .map(str::trim)
513 .filter(|url| !url.is_empty())
514 .map(str::to_string),
515 router_min_confidence_bp,
516 router_setup_issue,
517 cross_provider_auto,
518 candidates,
519 })
520 }
521
522 /// Whether Auto routing may select `provider` (#4411).
523 pub(crate) fn auto_scope_allows(&self, provider_id: &str) -> bool {
524 self.cross_provider_auto || self.active_identity.key.as_str() == provider_id
525 }
526
527 pub(crate) fn candidate(&self, provider_id: &str, model: &str) -> Option<&ModelRouteCandidate> {
528 let model = model.trim();
529 self.candidates
530 .iter()
531 .find(|candidate| {
532 candidate.identity.key.as_str() == provider_id && candidate.model == model
533 })
534 .or_else(|| {
535 self.candidates.iter().find(|candidate| {
536 candidate.identity.key.as_str() == provider_id
537 && !candidate.user_declared
538 && candidate.model.eq_ignore_ascii_case(model)
539 })
540 })
541 }
542
543 pub(crate) fn active_default(&self) -> Option<&ModelRouteCandidate> {
544 self.candidates
545 .iter()
546 .find(|candidate| {
547 self.active_identity == candidate.identity && candidate.default_for_provider
548 })
549 .or_else(|| {
550 self.candidates.iter().find(|candidate| {
551 self.active_identity == candidate.identity && candidate.readiness.can_attempt()
552 })
553 })
554 .or_else(|| {
555 // Falling through to another provider is a cross-provider Auto
556 // route (#4411): allowed only under the persisted opt-in. With
557 // it off, an unusable active provider surfaces as "no runnable
558 // candidate" instead of silently borrowing another provider's
559 // credentials.
560 self.cross_provider_auto
561 .then(|| {
562 self.candidates
563 .iter()
564 .find(|candidate| candidate.readiness.can_attempt())
565 })
566 .flatten()
567 })
568 }
569
570 pub(crate) fn router_context_json(&self) -> String {
571 #[derive(Serialize)]
572 struct RouterInventoryContext<'a> {
573 active_provider: &'a str,
574 candidates: Vec<RouterCandidateContext<'a>>,
575 }
576
577 #[derive(Serialize)]
578 struct RouterCandidateContext<'a> {
579 provider: &'a str,
580 provider_name: &'a str,
581 provider_display_name: &'a str,
582 model: &'a str,
583 context_window: u32,
584 #[serde(skip_serializing_if = "std::ops::Not::not")]
585 context_window_unverified: bool,
586 #[serde(skip_serializing_if = "Option::is_none")]
587 max_output: Option<u32>,
588 thinking_supported: bool,
589 cache_telemetry_supported: bool,
590 default_for_provider: bool,
591 tags: &'a [&'static str],
592 }
593
594 // The classifier needs route capabilities, not credentials, endpoint
595 // configuration, or provider error text. Filter to runnable candidates
596 // and project only non-secret routing facts before serializing.
597 //
598 // Scope (#4411): without the persisted `[auto] cross_provider` opt-in,
599 // the payload names only the active provider's routes. Which other
600 // providers a user has credentials for is not something Auto discloses
601 // to a classifier by default.
602 let candidates = self
603 .candidates
604 .iter()
605 .filter(|candidate| {
606 candidate.readiness.can_attempt()
607 && self.auto_scope_allows(candidate.identity.key.as_str())
608 })
609 .map(|candidate| RouterCandidateContext {
610 provider: &candidate.provider_tag,
611 provider_name: &candidate.provider_name,
612 provider_display_name: &candidate.provider_display_name,
613 model: &candidate.model,
614 context_window: candidate.context_window,
615 context_window_unverified: candidate.context_window_unverified,
616 max_output: candidate.max_output,
617 thinking_supported: candidate.thinking_supported,
618 cache_telemetry_supported: candidate.cache_telemetry_supported,
619 default_for_provider: candidate.default_for_provider,
620 tags: &candidate.tags,
621 })
622 .collect();
623 serde_json::to_string(&RouterInventoryContext {
624 active_provider: self.active_identity.key.as_str(),
625 candidates,
626 })
627 .unwrap_or_else(|_| "{}".to_string())
628 }
629 }
630
631 fn push_model(models: &mut Vec<String>, provider: ProviderKind, model: &str) {
632 if provider == ProviderKind::Ollama && crate::config::is_unresolved_local_ollama_model(model) {
633 return;
634 }
635 let Some(model) = normalize_model_name_for_provider(provider, model)
636 .or_else(|| crate::config::normalize_custom_model_id(model))
637 else {
638 return;
639 };
640 if !models
641 .iter()
642 .any(|existing| existing.eq_ignore_ascii_case(&model))
643 {
644 models.push(model);
645 }
646 }
647
648 fn configured_model_for_provider(config: &Config, identity: &ProviderIdentity) -> Option<String> {
649 config
650 .provider_config_for(identity)
651 .and_then(|entry| entry.model.clone())
652 .map(|model| model.trim().to_string())
653 .filter(|model| !model.is_empty())
654 }
655
656 pub(crate) fn provider_default_model(config: &Config, identity: &ProviderIdentity) -> String {
657 let provider = identity.provider;
658 let configured = configured_model_for_provider(config, identity).or_else(|| {
659 (config.active_provider_identity().ok().as_ref() == Some(identity)
660 && config.default_text_model.is_some())
661 .then(|| config.default_model())
662 });
663 let selector = configured.as_deref().filter(|model| {
664 !model.trim().eq_ignore_ascii_case("auto")
665 && !(provider == ProviderKind::Ollama
666 && crate::config::is_unresolved_local_ollama_model(model))
667 });
668 // Inventory labels must use the executable route's exact endpoint default,
669 // not whichever provider-wide snapshot happened to refresh most recently.
670 crate::route_runtime::resolve_runtime_route_for_identity(config, identity, selector)
671 .map(|route| route.model)
672 .unwrap_or_else(|_| {
673 configured.unwrap_or_else(|| {
674 identity
675 .compatibility()
676 .map_or("", |row| row.default_model)
677 .to_string()
678 })
679 })
680 }
681
682 fn auth_source_for_provider(
683 config: &Config,
684 identity: &ProviderIdentity,
685 ) -> Option<ModelAuthSource> {
686 let provider = identity.provider;
687 let credential_state =
688 crate::provider_readiness::credential_state_for_provider(config, identity);
689 match credential_state {
690 crate::provider_readiness::CredentialState::NoAuth => {
691 return Some(ModelAuthSource::NoAuth);
692 }
693 crate::provider_readiness::CredentialState::Local => {
694 return Some(ModelAuthSource::KeylessLocal);
695 }
696 crate::provider_readiness::CredentialState::ImportedToken => {
697 return Some(ModelAuthSource::ImportedToken);
698 }
699 crate::provider_readiness::CredentialState::MissingKey
700 | crate::provider_readiness::CredentialState::MissingLogin
701 | crate::provider_readiness::CredentialState::ExternalConsent
702 | crate::provider_readiness::CredentialState::Legacy => return None,
703 crate::provider_readiness::CredentialState::Saved => {}
704 }
705
706 if provider == ProviderKind::Custom {
707 let configured = config.provider_config_for(identity)?;
708 if configured
709 .api_key_env
710 .as_deref()
711 .map(str::trim)
712 .filter(|name| !name.is_empty())
713 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
714 {
715 return Some(ModelAuthSource::Env);
716 }
717 return (configured.api_key.as_deref().is_some_and(|value| {
718 crate::config::classify_config_api_key_value(value)
719 == crate::config::ConfigApiKeyValueKind::Literal
720 }) || crate::config::explicit_cli_api_key_override().is_some())
721 .then_some(ModelAuthSource::Config);
722 }
723 if provider_uses_oauth_cli(config, identity) {
724 return Some(ModelAuthSource::OAuthCli);
725 }
726 if config
727 .provider_config_for(identity)
728 .and_then(|entry| entry.api_key_env.as_deref())
729 .map(str::trim)
730 .filter(|name| !name.is_empty())
731 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
732 {
733 return Some(ModelAuthSource::Env);
734 }
735 if !config.should_skip_secret_store_for_provider(identity) && env_has_key_for(provider) {
736 return Some(ModelAuthSource::Env);
737 }
738 Some(ModelAuthSource::Config)
739 }
740
741 fn provider_uses_oauth_cli(config: &Config, identity: &ProviderIdentity) -> bool {
742 let provider = identity.provider;
743 if config.provider_uses_custom_endpoint(identity) {
744 return false;
745 }
746 match provider {
747 ProviderKind::OpenaiCodex => true,
748 ProviderKind::Xai => config
749 .provider_config_for(identity)
750 .and_then(|entry| entry.auth_mode.as_deref())
751 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth),
752 _ => false,
753 }
754 }
755
756 fn env_has_key_for(provider: ProviderKind) -> bool {
757 env_keys_for_provider(provider)
758 .iter()
759 .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
760 }
761
762 fn env_keys_for_provider(provider: ProviderKind) -> &'static [&'static str] {
763 provider.provider().env_vars()
764 }
765
766 #[cfg(test)]
767 mod tests {
768 use super::*;
769
770 #[test]
771 fn inventory_env_keys_follow_provider_metadata() {
772 for provider in ProviderKind::all() {
773 assert_eq!(
774 env_keys_for_provider(*provider),
775 provider.provider().env_vars()
776 );
777 }
778 }
779
780 #[test]
781 fn inventory_includes_only_usable_authenticated_providers() {
782 let _env_lock = crate::test_support::lock_test_env();
783 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
784 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
785 let _minimax = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
786 let config = Config {
787 provider: Some("zai".to_string()),
788 default_text_model: Some("deepseek-v4-pro".to_string()),
789 ..Default::default()
790 };
791
792 let inventory = ModelInventory::from_config(&config).unwrap();
793
794 // A DeepSeek key alone no longer elects a network classifier: with no
795 // explicit `[auto.router]`, legacy Auto stays local/free.
796 assert!(!inventory.router_configured);
797 assert!(!inventory.router_available);
798 assert!(
799 inventory
800 .candidate(ProviderKind::Zai.as_str(), crate::config::ZAI_GLM_5_2_MODEL)
801 .is_some()
802 );
803 assert!(
804 inventory
805 .candidates
806 .iter()
807 .all(|candidate| candidate.provider != ProviderKind::Minimax)
808 );
809 }
810
811 #[test]
812 fn inventory_marks_local_providers_keyless() {
813 let _env_lock = crate::test_support::lock_test_env();
814 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
815 let mut config = Config::default();
816 config
817 .set_provider_model_override(
818 &config.test_identity_for_kind(ProviderKind::Ollama),
819 Some("local-tag:latest".into()),
820 )
821 .unwrap();
822
823 let inventory = ModelInventory::from_config(&config).unwrap();
824
825 assert!(
826 inventory
827 .candidates
828 .iter()
829 .any(|candidate| candidate.provider == ProviderKind::Ollama
830 && candidate.auth_source == ModelAuthSource::KeylessLocal)
831 );
832 }
833
834 #[test]
835 fn inventory_never_marks_ollama_cloud_keyless_or_local() {
836 let _env_lock = crate::test_support::lock_test_env();
837 let _cloud_env = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
838 let _official_env = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
839 let config = Config {
840 provider: Some("ollama-cloud".to_string()),
841 providers: Some(crate::config::ProvidersConfig {
842 ollama_cloud: crate::config::ProviderConfig {
843 api_key: Some("cloud-key".to_string()),
844 ..Default::default()
845 },
846 ..Default::default()
847 }),
848 ..Default::default()
849 };
850
851 let inventory = ModelInventory::from_config(&config).unwrap();
852 let candidate = inventory
853 .candidate(
854 ProviderKind::OllamaCloud.as_str(),
855 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL,
856 )
857 .expect("authenticated Ollama Cloud candidate");
858 assert_eq!(candidate.auth_source, ModelAuthSource::Config);
859 assert!(!candidate.tags.contains(&"local"));
860 assert_ne!(candidate.readiness.label(), "local · not checked");
861 }
862
863 #[test]
864 fn inventory_never_admits_kimi_cli_oauth_import() {
865 let _env_lock = crate::test_support::lock_test_env();
866 let temp = tempfile::tempdir().expect("Kimi import fixture root");
867 let kimi_home = temp.path().join("kimi-code");
868 std::fs::create_dir_all(kimi_home.join("credentials")).expect("Kimi credential directory");
869 let expires_at = std::time::SystemTime::now()
870 .duration_since(std::time::UNIX_EPOCH)
871 .expect("clock after epoch")
872 .as_secs_f64()
873 + 3600.0;
874 let credential_path = kimi_home.join("credentials/kimi-code.json");
875 let credential_raw = serde_json::json!({
876 "access_token": "unexpired-user-owned-token",
877 "refresh_token": "must-not-be-used",
878 "expires_at": expires_at,
879 })
880 .to_string();
881 std::fs::write(&credential_path, &credential_raw).expect("write Kimi import fixture");
882 let _kimi_home = crate::test_support::EnvVarGuard::set(
883 "KIMI_CODE_HOME",
884 kimi_home.to_str().expect("utf8 path"),
885 );
886 let config = Config {
887 provider: Some("moonshot".to_string()),
888 providers: Some(crate::config::ProvidersConfig {
889 moonshot: crate::config::ProviderConfig {
890 auth_mode: Some("kimi_oauth".to_string()),
891 ..Default::default()
892 },
893 ..Default::default()
894 }),
895 ..Default::default()
896 };
897
898 let inventory = ModelInventory::from_config(&config).unwrap();
899 assert!(
900 inventory
901 .candidates
902 .iter()
903 .all(|candidate| candidate.provider != ProviderKind::Moonshot),
904 "unsupported Kimi CLI OAuth must not enter the routing inventory"
905 );
906 assert_eq!(
907 std::fs::read_to_string(credential_path).expect("Kimi file remains untouched"),
908 credential_raw
909 );
910 }
911
912 #[test]
913 fn inventory_uses_kimi_code_k3_route_context_not_generic_fallback() {
914 let config = Config {
915 provider: Some("moonshot".to_string()),
916 providers: Some(crate::config::ProvidersConfig {
917 moonshot: crate::config::ProviderConfig {
918 api_key: Some("test-kimi-key".to_string()),
919 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
920 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
921 ..Default::default()
922 },
923 ..Default::default()
924 }),
925 ..Default::default()
926 };
927
928 let inventory = ModelInventory::from_config(&config).unwrap();
929 let candidate = inventory
930 .candidate(
931 ProviderKind::Moonshot.as_str(),
932 crate::config::KIMI_CODE_K3_MODEL,
933 )
934 .expect("configured Kimi Code K3 route");
935
936 assert_eq!(candidate.context_window, 262_144);
937 assert!(candidate.thinking_supported);
938 assert!(candidate.tags.contains(&"thinking"));
939 assert!(!candidate.tags.contains(&"long_context"));
940 }
941
942 /// #5441: the auto-router inventory carries a `_Nk` name-suffix window
943 /// together with the fact that nobody verified it, so a classifier never
944 /// reads a naming convention as a route capability.
945 #[test]
946 fn router_inventory_marks_name_suffix_windows_unverified() {
947 let config = Config {
948 provider: Some("vllm".to_string()),
949 providers: Some(crate::config::ProvidersConfig {
950 vllm: crate::config::ProviderConfig {
951 base_url: Some("http://localhost:8000/v1".to_string()),
952 model: Some("qwen3-32b-256k".to_string()),
953 ..Default::default()
954 },
955 ..Default::default()
956 }),
957 ..Default::default()
958 };
959
960 let inventory = ModelInventory::from_config(&config).unwrap();
961 let candidate = inventory
962 .candidate(ProviderKind::Vllm.as_str(), "qwen3-32b-256k")
963 .expect("configured self-hosted route");
964 assert_eq!(candidate.context_window, 256_000);
965 assert!(
966 candidate.context_window_unverified,
967 "a name-suffix window must not enter the router payload as a fact"
968 );
969
970 let payload = inventory.router_context_json();
971 assert!(
972 payload.contains("\"context_window_unverified\":true"),
973 "payload must serialize the marker: {payload}"
974 );
975 }
976
977 #[test]
978 fn inventory_includes_custom_api_key_env_route() {
979 let _env_lock = crate::test_support::lock_test_env();
980 let _custom_key = crate::test_support::EnvVarGuard::set("ACME_CUSTOM_KEY", "custom-key");
981 let config = Config {
982 provider: Some("acme".to_string()),
983 providers: Some(crate::config::ProvidersConfig {
984 custom: std::collections::HashMap::from([(
985 "acme".to_string(),
986 crate::config::ProviderConfig {
987 kind: Some("openai-compatible".to_string()),
988 base_url: Some("https://api.acme.test/v1".to_string()),
989 model: Some("acme-coder".to_string()),
990 api_key_env: Some("ACME_CUSTOM_KEY".to_string()),
991 ..Default::default()
992 },
993 )]),
994 ..Default::default()
995 }),
996 ..Default::default()
997 };
998
999 let inventory = ModelInventory::from_config(&config).unwrap();
1000 assert!(
1001 inventory
1002 .candidates
1003 .iter()
1004 .any(|candidate| candidate.provider == ProviderKind::Custom
1005 && candidate.model == "acme-coder"
1006 && candidate.auth_source == ModelAuthSource::Env)
1007 );
1008 }
1009
1010 #[test]
1011 fn inventory_router_timeout_secs_respects_config_with_clamp() {
1012 let _env_lock = crate::test_support::lock_test_env();
1013
1014 // Unset: the legacy default (4 s) survives.
1015 let config = Config {
1016 ..Default::default()
1017 };
1018 assert_eq!(
1019 ModelInventory::from_config(&config)
1020 .unwrap()
1021 .router_timeout_secs,
1022 4
1023 );
1024
1025 // Explicit value is honored.
1026 let config = Config {
1027 auto: Some(crate::config::AutoConfig {
1028 router: Some(crate::config::AutoRouterConfig {
1029 provider: Some("custom".to_string()),
1030 model: Some("local-router".to_string()),
1031 thinking: None,
1032 timeout_secs: Some(15),
1033 ..Default::default()
1034 }),
1035 ..Default::default()
1036 }),
1037 ..Default::default()
1038 };
1039 assert_eq!(
1040 ModelInventory::from_config(&config)
1041 .unwrap()
1042 .router_timeout_secs,
1043 15
1044 );
1045
1046 // Out-of-range values clamp to the safety ceiling, never to zero.
1047 let config = Config {
1048 auto: Some(crate::config::AutoConfig {
1049 router: Some(crate::config::AutoRouterConfig {
1050 provider: Some("custom".to_string()),
1051 model: Some("local-router".to_string()),
1052 thinking: None,
1053 timeout_secs: Some(9_999),
1054 ..Default::default()
1055 }),
1056 ..Default::default()
1057 }),
1058 ..Default::default()
1059 };
1060 assert_eq!(
1061 ModelInventory::from_config(&config)
1062 .unwrap()
1063 .router_timeout_secs,
1064 crate::config::MAX_AUTO_ROUTER_TIMEOUT_SECS
1065 );
1066
1067 // Zero means "use the default", not an instant timeout.
1068 let config = Config {
1069 auto: Some(crate::config::AutoConfig {
1070 router: Some(crate::config::AutoRouterConfig {
1071 provider: Some("custom".to_string()),
1072 model: Some("local-router".to_string()),
1073 thinking: None,
1074 timeout_secs: Some(0),
1075 ..Default::default()
1076 }),
1077 ..Default::default()
1078 }),
1079 ..Default::default()
1080 };
1081 assert_eq!(
1082 ModelInventory::from_config(&config)
1083 .unwrap()
1084 .router_timeout_secs,
1085 4
1086 );
1087 }
1088
1089 #[test]
1090 fn inventory_ignores_unresolved_command_and_secret_auth_metadata() {
1091 let _env_lock = crate::test_support::lock_test_env();
1092 let temp = tempfile::tempdir().expect("isolated credential home");
1093 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1094 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1095 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
1096 let _openai = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
1097 let _xai = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1098 let mut providers = crate::config::ProvidersConfig::default();
1099 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
1100 source: codewhale_config::AuthSourceKind::Command,
1101 command: vec!["secret-tool".to_string(), "lookup".to_string()],
1102 timeout_ms: Some(2000),
1103 secret_id: None,
1104 });
1105 providers.xai.auth = Some(codewhale_config::ProviderAuthSourceToml {
1106 source: codewhale_config::AuthSourceKind::Secret,
1107 command: Vec::new(),
1108 timeout_ms: None,
1109 secret_id: Some("codewhale/xai".to_string()),
1110 });
1111 let config = Config {
1112 provider: Some("openai".to_string()),
1113 providers: Some(providers),
1114 ..Default::default()
1115 };
1116
1117 let inventory = ModelInventory::from_config(&config).unwrap();
1118 assert!(inventory.candidates.iter().all(|candidate| !matches!(
1119 candidate.provider,
1120 ProviderKind::Openai | ProviderKind::Xai
1121 )));
1122 }
1123
1124 #[test]
1125 fn auto_router_config_overrides_default_classifier_route() {
1126 let config = Config {
1127 auto: Some(crate::config::AutoConfig {
1128 cost_saving: None,
1129 cross_provider: None,
1130 router: Some(crate::config::AutoRouterConfig {
1131 provider: Some("zai".to_string()),
1132 model: Some("glm-5-turbo".to_string()),
1133 thinking: Some("low".to_string()),
1134 timeout_secs: None,
1135 ..Default::default()
1136 }),
1137 }),
1138 ..Default::default()
1139 };
1140
1141 let inventory = ModelInventory::from_config(&config).unwrap();
1142 assert!(inventory.router_configured);
1143 assert_eq!(inventory.router_provider, ProviderKind::Zai);
1144 assert_eq!(inventory.router_model, "glm-5-turbo");
1145 assert_eq!(inventory.router_thinking.as_deref(), Some("low"));
1146 }
1147
1148 /// A DeepSeek key must never, on its own, turn on a network classifier.
1149 /// `[auto.router]` stays legacy `model = auto` configuration; absent it,
1150 /// legacy Auto is local/free.
1151 #[test]
1152 fn a_deepseek_key_alone_never_elects_an_implicit_flash_classifier() {
1153 let _env_lock = crate::test_support::lock_test_env();
1154 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1155 let config = Config {
1156 provider: Some("deepseek".to_string()),
1157 ..Default::default()
1158 };
1159
1160 let inventory = ModelInventory::from_config(&config).unwrap();
1161
1162 assert!(
1163 !inventory.router_configured,
1164 "no [auto.router] means no configured classifier"
1165 );
1166 assert!(
1167 !inventory.router_available,
1168 "holding a DeepSeek key must not silently select deepseek-v4-flash as a classifier"
1169 );
1170 }
1171
1172 #[test]
1173 fn an_explicit_legacy_auto_router_still_works_when_its_key_is_present() {
1174 let _env_lock = crate::test_support::lock_test_env();
1175 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1176 let config = Config {
1177 auto: Some(crate::config::AutoConfig {
1178 cost_saving: None,
1179 router: Some(crate::config::AutoRouterConfig {
1180 provider: Some("zai".to_string()),
1181 model: Some("glm-5-turbo".to_string()),
1182 thinking: None,
1183 timeout_secs: None,
1184 ..Default::default()
1185 }),
1186 cross_provider: None,
1187 }),
1188 ..Default::default()
1189 };
1190
1191 let inventory = ModelInventory::from_config(&config).unwrap();
1192
1193 assert!(inventory.router_configured);
1194 assert!(inventory.router_available);
1195 assert_eq!(inventory.router_model, "glm-5-turbo");
1196 }
1197
1198 #[test]
1199 fn inventory_marks_explicit_no_auth_separately_from_keyless_local() {
1200 let mut providers = crate::config::ProvidersConfig::default();
1201 providers.vllm.auth_mode = Some("none".to_string());
1202 providers.vllm.model = Some("local-model".to_string());
1203 let config = Config {
1204 provider: Some("vllm".to_string()),
1205 providers: Some(providers),
1206 ..Default::default()
1207 };
1208
1209 let inventory = ModelInventory::from_config(&config).unwrap();
1210 let candidate = inventory
1211 .candidates
1212 .iter()
1213 .find(|candidate| {
1214 candidate.provider == ProviderKind::Vllm && candidate.model == "local-model"
1215 })
1216 .expect("vLLM no-auth candidate");
1217
1218 assert_eq!(candidate.auth_source, ModelAuthSource::NoAuth);
1219 assert_eq!(
1220 candidate.readiness,
1221 crate::provider_readiness::ResolvedProviderReadiness::NoAuthUnchecked
1222 );
1223 }
1224
1225 #[test]
1226 fn unready_candidates_are_never_provider_defaults() {
1227 use crate::provider_readiness::ResolvedProviderReadiness;
1228
1229 let candidate = ModelRouteCandidate {
1230 provider: ProviderKind::Openai,
1231 provider_tag: "openai".into(),
1232 identity: Config::default()
1233 .builtin_provider_identity(ProviderKind::Openai)
1234 .unwrap(),
1235 provider_name: "openai".into(),
1236 provider_display_name: "OpenAI".into(),
1237 model: "gpt-5.5".to_string(),
1238 context_window: 128_000,
1239 context_window_unverified: false,
1240 user_declared: false,
1241 max_output: Some(16_384),
1242 thinking_supported: true,
1243 cache_telemetry_supported: false,
1244 auth_source: ModelAuthSource::Config,
1245 readiness: ResolvedProviderReadiness::MissingLogin,
1246 default_for_provider: false,
1247 tags: vec!["unready"],
1248 };
1249 assert!(!candidate.readiness.can_attempt());
1250 assert!(!candidate.default_for_provider);
1251 assert!(candidate.tags.contains(&"unready"));
1252 }
1253
1254 #[test]
1255 fn active_default_never_falls_back_to_unready_candidate() {
1256 let inventory = ModelInventory {
1257 active_provider: ProviderKind::Openai,
1258 active_identity: Config::default()
1259 .builtin_provider_identity(ProviderKind::Openai)
1260 .unwrap(),
1261 router_provider: ProviderKind::Deepseek,
1262 router_identity: None,
1263 router_model: "deepseek-v4-flash".to_string(),
1264 router_thinking: None,
1265 router_timeout_secs: 4,
1266 router_configured: false,
1267 router_available: false,
1268 router_kind: AutoRouterKind::Chat,
1269 router_decision_route: None,
1270 router_base_url: None,
1271 router_min_confidence_bp: 5_000,
1272 router_setup_issue: None,
1273 cross_provider_auto: false,
1274 candidates: vec![ModelRouteCandidate {
1275 provider: ProviderKind::Openai,
1276 provider_tag: "openai".into(),
1277 identity: Config::default()
1278 .builtin_provider_identity(ProviderKind::Openai)
1279 .unwrap(),
1280 provider_name: "openai".into(),
1281 provider_display_name: "OpenAI".into(),
1282 model: "unsupported-model".to_string(),
1283 context_window: 1,
1284 context_window_unverified: false,
1285 user_declared: false,
1286 max_output: Some(1),
1287 thinking_supported: false,
1288 cache_telemetry_supported: false,
1289 auth_source: ModelAuthSource::Config,
1290 readiness: crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
1291 default_for_provider: false,
1292 tags: vec!["unready"],
1293 }],
1294 };
1295
1296 assert!(inventory.active_default().is_none());
1297 }
1298
1299 #[test]
1300 fn router_context_is_runnable_and_redacts_auth_and_failure_details() {
1301 let _env_lock = crate::test_support::lock_test_env();
1302 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1303 let mut inventory = ModelInventory::from_config(&Config::default()).unwrap();
1304 let candidate = inventory
1305 .candidates
1306 .iter_mut()
1307 .find(|candidate| candidate.provider == ProviderKind::Deepseek)
1308 .expect("DeepSeek inventory candidate");
1309 candidate.readiness =
1310 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed {
1311 category: crate::error_taxonomy::ErrorCategory::Authentication,
1312 message: "Bearer super-secret-router-token".to_string(),
1313 };
1314 inventory.candidates.push(ModelRouteCandidate {
1315 provider: ProviderKind::Openai,
1316 provider_tag: "openai".into(),
1317 identity: Config::default()
1318 .builtin_provider_identity(ProviderKind::Openai)
1319 .unwrap(),
1320 provider_name: "openai".into(),
1321 provider_display_name: "OpenAI".into(),
1322 model: "unsupported-model".to_string(),
1323 context_window: 1,
1324 context_window_unverified: false,
1325 user_declared: false,
1326 max_output: Some(1),
1327 thinking_supported: false,
1328 cache_telemetry_supported: false,
1329 auth_source: ModelAuthSource::Config,
1330 readiness: crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
1331 default_for_provider: false,
1332 tags: vec!["unready"],
1333 });
1334
1335 let json = inventory.router_context_json();
1336
1337 assert!(json.contains("deepseek-v4"));
1338 assert!(!json.contains("super-secret-router-token"));
1339 assert!(!json.contains("auth_source"));
1340 assert!(!json.contains("unsupported-model"));
1341 }
1342
1343 #[test]
1344 fn router_context_names_only_the_active_provider_by_default() {
1345 // #4411: a Z.ai session with a DeepSeek key in the environment must
1346 // not disclose the DeepSeek routes — or the fact that a DeepSeek
1347 // credential exists — to the classifier.
1348 let _env_lock = crate::test_support::lock_test_env();
1349 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1350 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1351 let config = Config {
1352 provider: Some("zai".to_string()),
1353 ..Default::default()
1354 };
1355
1356 let inventory = ModelInventory::from_config(&config).unwrap();
1357 assert!(
1358 inventory
1359 .candidates
1360 .iter()
1361 .any(|candidate| candidate.provider == ProviderKind::Deepseek),
1362 "the full inventory still knows about DeepSeek for pickers/explicit routes"
1363 );
1364
1365 let json = inventory.router_context_json();
1366 let payload: serde_json::Value =
1367 serde_json::from_str(&json).expect("router context is JSON");
1368 let providers: Vec<&str> = payload["candidates"]
1369 .as_array()
1370 .expect("candidate array")
1371 .iter()
1372 .map(|candidate| candidate["provider_name"].as_str().expect("provider name"))
1373 .collect();
1374
1375 assert!(!providers.is_empty(), "active provider routes must remain");
1376 assert!(
1377 providers.iter().all(|provider| *provider == "zai"),
1378 "classifier payload leaked another provider: {json}"
1379 );
1380 assert!(!json.contains("deepseek"), "{json}");
1381 }
1382
1383 #[test]
1384 fn router_context_includes_other_providers_under_persisted_opt_in() {
1385 let _env_lock = crate::test_support::lock_test_env();
1386 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1387 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1388 let config = Config {
1389 provider: Some("zai".to_string()),
1390 auto: Some(crate::config::AutoConfig {
1391 cost_saving: None,
1392 cross_provider: Some(true),
1393 router: None,
1394 }),
1395 ..Default::default()
1396 };
1397
1398 let json = ModelInventory::from_config(&config)
1399 .unwrap()
1400 .router_context_json();
1401
1402 assert!(json.contains("\"zai\""), "{json}");
1403 assert!(json.contains("deepseek"), "{json}");
1404 }
1405
1406 #[test]
1407 fn implicit_deepseek_classifier_is_out_of_scope_for_another_active_provider() {
1408 // #4411: the default classifier route is DeepSeek flash. Calling it
1409 // from a Z.ai session would send the turn's prompt to a second
1410 // provider, so it stays unavailable without an explicit opt-in.
1411 let _env_lock = crate::test_support::lock_test_env();
1412 let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key");
1413 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
1414 let zai = Config {
1415 provider: Some("zai".to_string()),
1416 ..Default::default()
1417 };
1418 assert!(!ModelInventory::from_config(&zai).unwrap().router_available);
1419
1420 // `cross_provider = true` widens which candidates Auto may pick; it is
1421 // NOT a classifier election. With the implicit DeepSeek-flash default
1422 // removed, no network classifier runs without an explicit
1423 // `[auto.router]` route — a scope opt-in alone stays local/free.
1424 let opted_in = Config {
1425 auto: Some(crate::config::AutoConfig {
1426 cost_saving: None,
1427 cross_provider: Some(true),
1428 router: None,
1429 }),
1430 ..zai.clone()
1431 };
1432 let widened = ModelInventory::from_config(&opted_in).unwrap();
1433 assert!(!widened.router_available);
1434 assert!(widened.auto_scope_allows(ProviderKind::Deepseek.as_str()));
1435
1436 // An explicitly configured `[auto.router]` is itself a persisted
1437 // opt-in for that classifier route.
1438 let explicit_router = Config {
1439 auto: Some(crate::config::AutoConfig {
1440 cost_saving: None,
1441 cross_provider: None,
1442 router: Some(crate::config::AutoRouterConfig {
1443 provider: Some("deepseek".to_string()),
1444 model: Some("deepseek-v4-flash".to_string()),
1445 thinking: None,
1446 timeout_secs: None,
1447 ..Default::default()
1448 }),
1449 }),
1450 ..zai.clone()
1451 };
1452 assert!(
1453 ModelInventory::from_config(&explicit_router)
1454 .unwrap()
1455 .router_available
1456 );
1457
1458 // A DeepSeek session gets no free classifier either: with the
1459 // implicit flash default removed, only an explicit `[auto.router]`
1460 // elects a network classifier, active provider or not.
1461 let deepseek = Config {
1462 provider: Some("deepseek".to_string()),
1463 ..Default::default()
1464 };
1465 assert!(
1466 !ModelInventory::from_config(&deepseek)
1467 .unwrap()
1468 .router_available
1469 );
1470 }
1471
1472 #[test]
1473 fn declared_inventory_ids_remain_case_distinct() {
1474 let _env = crate::test_support::lock_test_env();
1475 let home = tempfile::tempdir().unwrap();
1476 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1477 let mut config: Config = toml::from_str(include_str!(
1478 "../../config/tests/fixtures/custom_models.toml"
1479 ))
1480 .unwrap();
1481 let declaration = config.custom_models.as_mut().unwrap().first_mut().unwrap();
1482 declaration.id = "Preview-fixture".into();
1483 let mut other = declaration.clone();
1484 other.id = "preview-fixture".into();
1485 other.limit.as_mut().unwrap().context = Some(128000);
1486 config.custom_models.as_mut().unwrap().push(other);
1487 config
1488 .set_provider_api_key_override(
1489 &config.test_identity_for_kind(ProviderKind::Deepseek),
1490 Some("fixture-key".into()),
1491 )
1492 .unwrap();
1493 let inventory = ModelInventory::from_config(&config).unwrap();
1494 for (id, context) in [("Preview-fixture", 96000), ("preview-fixture", 128000)] {
1495 let candidate = inventory
1496 .candidate(ProviderKind::Deepseek.as_str(), id)
1497 .unwrap();
1498 assert!(candidate.user_declared);
1499 assert_eq!(candidate.model, id);
1500 assert_eq!(candidate.context_window, context);
1501 }
1502 assert!(
1503 inventory
1504 .candidate(ProviderKind::Deepseek.as_str(), "PREVIEW-FIXTURE")
1505 .is_none()
1506 );
1507 }
1508
1509 #[test]
1510 fn ollama_inventory_default_uses_only_the_fresh_exact_endpoint_roster() {
1511 use codewhale_config::catalog::{
1512 CatalogOffering, CatalogRefreshError, CatalogSource, ProviderCatalogDelta,
1513 base_url_fingerprint, now_unix,
1514 };
1515
1516 let _env = crate::test_support::lock_test_env();
1517 let _live = crate::provider_lake::lock_live_snapshot();
1518 let home = tempfile::tempdir().unwrap();
1519 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1520 crate::provider_catalog_live::reset_cache_for_test();
1521 crate::provider_lake::clear_live_snapshot();
1522 let mut config = Config {
1523 provider: Some("ollama".to_string()),
1524 ..Default::default()
1525 };
1526 let endpoint = "http://localhost:11445/v1";
1527 config
1528 .provider_config_for_mut(&config.test_identity_for_kind(ProviderKind::Ollama))
1529 .unwrap()
1530 .base_url = Some(endpoint.into());
1531 assert_eq!(
1532 provider_default_model(
1533 &config,
1534 &(config).test_identity_for_kind(ProviderKind::Ollama)
1535 ),
1536 "unknown"
1537 );
1538 assert!(
1539 ModelInventory::from_config(&config)
1540 .unwrap()
1541 .candidates
1542 .iter()
1543 .all(|row| { row.provider != ProviderKind::Ollama || row.model != "unknown" })
1544 );
1545 let fingerprint = base_url_fingerprint(endpoint);
1546 let now = now_unix();
1547 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
1548 ProviderKind::Ollama,
1549 "ollama",
1550 endpoint,
1551 );
1552 crate::provider_catalog_live::record_success_if_current(
1553 &ticket,
1554 ProviderCatalogDelta {
1555 provider: "ollama".into(),
1556 base_url_fingerprint: fingerprint.clone(),
1557 fetched_at: now,
1558 offerings: vec![CatalogOffering {
1559 provider: "ollama".into(),
1560 wire_model_id: "qwen2.5:0.5b".into(),
1561 endpoint_key: "chat".into(),
1562 source: CatalogSource::Live {
1563 base_url_fingerprint: fingerprint.clone(),
1564 fetched_at: now,
1565 },
1566 ..Default::default()
1567 }],
1568 },
1569 );
1570 assert_eq!(
1571 provider_default_model(
1572 &config,
1573 &(config).test_identity_for_kind(ProviderKind::Ollama)
1574 ),
1575 "qwen2.5:0.5b"
1576 );
1577 let inventory = ModelInventory::from_config(&config).unwrap();
1578 assert!(inventory.candidates.iter().any(|row| {
1579 row.provider == ProviderKind::Ollama
1580 && row.model == "qwen2.5:0.5b"
1581 && row.default_for_provider
1582 }));
1583 let mut other = config.clone();
1584 other
1585 .provider_config_for_mut(&other.test_identity_for_kind(ProviderKind::Ollama))
1586 .unwrap()
1587 .base_url = Some("http://localhost:11446/v1".into());
1588 assert_eq!(
1589 provider_default_model(
1590 &other,
1591 &(other).test_identity_for_kind(ProviderKind::Ollama)
1592 ),
1593 "unknown"
1594 );
1595 crate::provider_catalog_live::record_failure_if_current(
1596 &ticket,
1597 "ollama",
1598 &fingerprint,
1599 CatalogRefreshError::Network,
1600 );
1601 assert_eq!(
1602 provider_default_model(
1603 &config,
1604 &(config).test_identity_for_kind(ProviderKind::Ollama)
1605 ),
1606 "unknown"
1607 );
1608 config
1609 .set_provider_model_override(
1610 &config.test_identity_for_kind(ProviderKind::Ollama),
1611 Some("chosen:tag".into()),
1612 )
1613 .unwrap();
1614 assert_eq!(
1615 provider_default_model(
1616 &config,
1617 &(config).test_identity_for_kind(ProviderKind::Ollama)
1618 ),
1619 "chosen:tag"
1620 );
1621 crate::provider_catalog_live::reset_cache_for_test();
1622 crate::provider_lake::clear_live_snapshot();
1623 }
1624 }
1625
1626 #[cfg(test)]
1627 mod decision_router_inventory_tests {
1628 use super::*;
1629
1630 fn with_router(router: crate::config::AutoRouterConfig, openrouter_key: bool) -> Config {
1631 Config {
1632 provider: Some("deepseek".to_string()),
1633 providers: Some(crate::config::ProvidersConfig {
1634 openrouter: crate::config::ProviderConfig {
1635 api_key: openrouter_key.then(|| "or-test-key".to_string()),
1636 ..Default::default()
1637 },
1638 ..Default::default()
1639 }),
1640 auto: Some(crate::config::AutoConfig {
1641 cost_saving: None,
1642 cross_provider: None,
1643 router: Some(router),
1644 }),
1645 ..Default::default()
1646 }
1647 }
1648
1649 fn decision(provider: &str) -> crate::config::AutoRouterConfig {
1650 crate::config::AutoRouterConfig {
1651 kind: Some("decision".to_string()),
1652 provider: Some(provider.to_string()),
1653 model: Some("typesafe/jev-1.13".to_string()),
1654 ..Default::default()
1655 }
1656 }
1657
1658 /// Tuple fields drop in order: restore the environment, then unlock.
1659 fn hermetic() -> (
1660 crate::test_support::EnvVarGuard,
1661 crate::test_support::EnvVarGuard,
1662 crate::test_support::TestEnvLock,
1663 ) {
1664 let lock = crate::test_support::lock_test_env();
1665 (
1666 crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY"),
1667 crate::test_support::EnvVarGuard::remove("TYPESAFE_API_KEY"),
1668 lock,
1669 )
1670 }
1671
1672 #[test]
1673 fn decision_router_on_openrouter_needs_the_openrouter_key() {
1674 let _env = hermetic();
1675 let with_key =
1676 ModelInventory::from_config(&with_router(decision("openrouter"), true)).unwrap();
1677 assert!(with_key.router_configured);
1678 assert!(with_key.router_available);
1679 assert_eq!(with_key.router_kind, AutoRouterKind::Decision);
1680 assert_eq!(
1681 with_key.router_decision_route,
1682 Some(DecisionRouterRoute::Openrouter)
1683 );
1684 assert_eq!(
1685 with_key.router_thinking, None,
1686 "decision models ignore thinking"
1687 );
1688 assert_eq!(with_key.router_min_confidence_bp, 5_000);
1689 assert_eq!(with_key.router_setup_issue, None);
1690
1691 let without_key =
1692 ModelInventory::from_config(&with_router(decision("openrouter"), false)).unwrap();
1693 assert!(without_key.router_configured);
1694 assert!(!without_key.router_available);
1695 assert_eq!(
1696 without_key.router_setup_issue,
1697 Some(AutoRouterSetupIssue::MissingKey)
1698 );
1699 }
1700
1701 #[test]
1702 fn unknown_kind_or_unsupported_decision_provider_is_not_configured() {
1703 let _env = hermetic();
1704 let bogus = crate::config::AutoRouterConfig {
1705 kind: Some("bogus".to_string()),
1706 ..decision("openrouter")
1707 };
1708 let inventory = ModelInventory::from_config(&with_router(bogus, true)).unwrap();
1709 assert!(!inventory.router_configured);
1710 assert!(!inventory.router_available);
1711 assert_eq!(
1712 inventory.router_setup_issue,
1713 Some(AutoRouterSetupIssue::UnknownKind)
1714 );
1715
1716 let zai = ModelInventory::from_config(&with_router(decision("zai"), true)).unwrap();
1717 assert!(!zai.router_configured);
1718 assert_eq!(
1719 zai.router_setup_issue,
1720 Some(AutoRouterSetupIssue::UnsupportedDecisionProvider)
1721 );
1722 }
1723
1724 #[test]
1725 fn a_chat_router_with_an_unknown_thinking_tier_is_not_configured() {
1726 let _env = hermetic();
1727 let chat = |thinking: &str| crate::config::AutoRouterConfig {
1728 kind: Some("chat".to_string()),
1729 provider: Some("openrouter".to_string()),
1730 model: Some("openai/gpt-5-mini".to_string()),
1731 thinking: Some(thinking.to_string()),
1732 ..Default::default()
1733 };
1734 let typo = ModelInventory::from_config(&with_router(chat("hgih"), true)).unwrap();
1735 assert!(!typo.router_configured);
1736 assert!(!typo.router_available);
1737 assert_eq!(
1738 typo.router_setup_issue,
1739 Some(AutoRouterSetupIssue::InvalidThinking)
1740 );
1741
1742 let valid = ModelInventory::from_config(&with_router(chat("low"), true)).unwrap();
1743 assert!(valid.router_available);
1744 assert_eq!(valid.router_setup_issue, None);
1745 assert_eq!(valid.router_thinking.as_deref(), Some("low"));
1746 }
1747
1748 #[test]
1749 fn a_chat_router_whose_provider_cannot_serve_the_model_is_not_configured() {
1750 let _env = hermetic();
1751 let chat = |provider: &str, model: &str| crate::config::AutoRouterConfig {
1752 kind: Some("chat".to_string()),
1753 provider: Some(provider.to_string()),
1754 model: Some(model.to_string()),
1755 ..Default::default()
1756 };
1757 // A model from another provider's namespace, either direction.
1758 for (provider, model) in [("deepseek", "gpt-5-mini"), ("zai", "deepseek-v4-flash")] {
1759 let wrong =
1760 ModelInventory::from_config(&with_router(chat(provider, model), true)).unwrap();
1761 assert!(!wrong.router_configured, "{provider}/{model}");
1762 assert_eq!(
1763 wrong.router_setup_issue,
1764 Some(AutoRouterSetupIssue::InvalidModel),
1765 "{provider}/{model}"
1766 );
1767 }
1768
1769 let valid =
1770 ModelInventory::from_config(&with_router(chat("deepseek", "deepseek-v4-flash"), true))
1771 .unwrap();
1772 assert!(valid.router_configured);
1773 assert_ne!(
1774 valid.router_setup_issue,
1775 Some(AutoRouterSetupIssue::InvalidModel)
1776 );
1777 }
1778
1779 #[test]
1780 fn a_router_table_with_only_tuning_keys_is_declared_and_incomplete() {
1781 let _env = hermetic();
1782 let tuning_only = [
1783 crate::config::AutoRouterConfig {
1784 timeout_secs: Some(3),
1785 ..Default::default()
1786 },
1787 crate::config::AutoRouterConfig {
1788 min_confidence: Some(0.6),
1789 ..Default::default()
1790 },
1791 crate::config::AutoRouterConfig {
1792 thinking: Some("off".to_string()),
1793 ..Default::default()
1794 },
1795 crate::config::AutoRouterConfig {
1796 base_url: Some("https://api.typesafe.ai/v1".to_string()),
1797 ..Default::default()
1798 },
1799 ];
1800 for router in tuning_only {
1801 let inventory =
1802 ModelInventory::from_config(&with_router(router.clone(), true)).unwrap();
1803 assert!(!inventory.router_available, "{router:?}");
1804 assert_eq!(
1805 inventory.router_setup_issue,
1806 Some(AutoRouterSetupIssue::Incomplete),
1807 "{router:?}"
1808 );
1809 }
1810 }
1811
1812 #[test]
1813 fn min_confidence_is_clamped() {
1814 let _env = hermetic();
1815 for (configured, expected) in [
1816 (Some(2.0), 10_000),
1817 (Some(-1.0), 0),
1818 (Some(0.35), 3_500),
1819 (None, 5_000),
1820 ] {
1821 let router = crate::config::AutoRouterConfig {
1822 min_confidence: configured,
1823 ..decision("openrouter")
1824 };
1825 assert_eq!(
1826 ModelInventory::from_config(&with_router(router, true))
1827 .unwrap()
1828 .router_min_confidence_bp,
1829 expected,
1830 "{configured:?}"
1831 );
1832 }
1833 }
1834 }
1835
1835 lines RUST