返回 CodeWhale
provider_readiness.rs
根目录 / crates / tui / src / provider_readiness.rs
1 //! Truthful, session-local provider readiness.
2 //!
3 //! Static configuration can prove that credential material exists, but not
4 //! that an endpoint is reachable or an OAuth token is still entitled to a
5 //! model. `Ready` is therefore reserved for observed success in this session.
6
7 use std::borrow::Cow;
8
9 use crate::config::ApiProvider;
10 use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope};
11 use codewhale_config::route::{LogicalModelRef, RouteRequest, RouteResolver};
12
13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 pub(crate) enum CredentialState {
15 MissingKey,
16 MissingLogin,
17 /// Structurally valid read-only consent exists for another CLI's file,
18 /// but the provider is not active so no read capability has been minted.
19 ExternalConsent,
20 Saved,
21 ImportedToken,
22 NoAuth,
23 Local,
24 Legacy,
25 }
26
27 /// Credential route whose observed health may be reused. A provider can
28 /// expose more than one auth route (notably xAI and Moonshot), so provider id
29 /// alone is not a safe cache key: a successful API-key request must not make a
30 /// newly selected imported-token or OAuth route appear verified.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 pub(crate) enum ProviderAuthClass {
33 ApiKey,
34 OAuth,
35 ImportedToken,
36 NoAuth,
37 Local,
38 Legacy,
39 }
40
41 /// Exact route whose observed health may be reused. This deliberately keeps
42 /// custom provider id, endpoint, model, and auth class together: success on
43 /// one private endpoint or model entitlement is not evidence for another.
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 pub(crate) struct ProviderRouteIdentity {
46 provider: ApiProvider,
47 provider_id: String,
48 endpoint: String,
49 model: String,
50 auth_class: ProviderAuthClass,
51 }
52
53 pub(crate) fn route_identity_for_model(
54 config: &crate::config::Config,
55 provider: ApiProvider,
56 model: &str,
57 ) -> ProviderRouteIdentity {
58 let configured = config.provider_config_for(provider);
59 let provider_id = if provider == ApiProvider::Custom {
60 config
61 .provider
62 .as_deref()
63 .map(str::trim)
64 .filter(|value| !value.is_empty())
65 .unwrap_or(provider.as_str())
66 } else {
67 provider.as_str()
68 };
69 let endpoint = if provider == config.api_provider() {
70 config.active_route_base_url()
71 } else {
72 configured
73 .and_then(|entry| entry.base_url.as_deref())
74 .map(str::trim)
75 .filter(|value| !value.is_empty())
76 .unwrap_or_else(|| {
77 if provider == ApiProvider::Moonshot
78 && configured.is_some_and(|entry| {
79 entry
80 .auth_mode
81 .as_deref()
82 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token)
83 })
84 {
85 crate::config::DEFAULT_KIMI_CODE_BASE_URL
86 } else {
87 provider.default_base_url()
88 }
89 })
90 .to_string()
91 }
92 .trim_end_matches('/')
93 .to_string();
94 ProviderRouteIdentity {
95 provider,
96 provider_id: provider_id.to_string(),
97 endpoint,
98 model: model.trim().to_string(),
99 auth_class: auth_class_for_provider(config, provider),
100 }
101 }
102
103 pub(crate) fn auth_class_for_provider(
104 config: &crate::config::Config,
105 provider: ApiProvider,
106 ) -> ProviderAuthClass {
107 let auth_mode = config.auth_mode_for_provider(provider);
108 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
109 return ProviderAuthClass::NoAuth;
110 }
111 let official_endpoint = !config.provider_uses_custom_endpoint(provider);
112 if provider == ApiProvider::OpenaiCodex && official_endpoint {
113 return ProviderAuthClass::OAuth;
114 }
115 if provider == ApiProvider::Moonshot
116 && official_endpoint
117 && auth_mode
118 .as_deref()
119 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token)
120 {
121 return ProviderAuthClass::ImportedToken;
122 }
123 if provider == ApiProvider::Xai
124 && official_endpoint
125 && auth_mode
126 .as_deref()
127 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth)
128 {
129 return ProviderAuthClass::OAuth;
130 }
131 match credential_state_for_provider(config, provider) {
132 CredentialState::NoAuth => ProviderAuthClass::NoAuth,
133 CredentialState::Local => ProviderAuthClass::Local,
134 CredentialState::Legacy => ProviderAuthClass::Legacy,
135 CredentialState::ExternalConsent => ProviderAuthClass::OAuth,
136 _ => ProviderAuthClass::ApiKey,
137 }
138 }
139
140 pub(crate) fn credential_state_for_provider(
141 config: &crate::config::Config,
142 provider: ApiProvider,
143 ) -> CredentialState {
144 let auth_mode = config.auth_mode_for_provider(provider);
145 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
146 return CredentialState::NoAuth;
147 }
148 let api_key_required = crate::config::auth_mode_requires_api_key(auth_mode.as_deref());
149 let official_endpoint = !config.provider_uses_custom_endpoint(provider);
150
151 // A built-in provider can intentionally target a local OpenAI-compatible
152 // runtime. That route is keyless unless the operator explicitly declares
153 // an API-key auth contract. Classify it before provider-specific hosted
154 // branches (including the DeepSeek-CN compatibility alias) so readiness
155 // and cache identity describe the effective endpoint, not just the
156 // provider enum.
157 if provider == config.api_provider()
158 && !official_endpoint
159 && crate::config::base_url_uses_local_host(&config.active_route_base_url())
160 {
161 return if api_key_required {
162 if crate::config::has_api_key_for(config, provider) {
163 CredentialState::Saved
164 } else {
165 CredentialState::MissingKey
166 }
167 } else {
168 CredentialState::Local
169 };
170 }
171
172 // DeepSeek CN is a TUI compatibility alias without a shared
173 // `ProviderKind`, but it is still a live route handled by the runtime.
174 // Treating it as `Legacy` makes setup claim it cannot run at all.
175 if provider == ApiProvider::DeepseekCN {
176 return if crate::config::has_api_key_for(config, provider) {
177 CredentialState::Saved
178 } else {
179 CredentialState::MissingKey
180 };
181 }
182 // The retired Antigravity identity keeps a `ProviderKind` only so legacy
183 // `[providers.antigravity]` tables deserialize and can be cleared. A
184 // leftover `api_key` in that table must never read as `Saved`: the route
185 // is a non-runnable tombstone, so `/model`, setup, and readiness treat it
186 // as legacy regardless of what the table contains.
187 if provider == ApiProvider::Antigravity || provider.kind().is_none() {
188 return CredentialState::Legacy;
189 }
190 if provider == ApiProvider::Custom {
191 if config.uses_legacy_literal_custom_route() {
192 if config
193 .base_url
194 .as_deref()
195 .is_some_and(crate::config::base_url_uses_local_host)
196 && !api_key_required
197 {
198 return CredentialState::Local;
199 }
200 return if crate::config::has_api_key_for(config, provider) {
201 CredentialState::Saved
202 } else {
203 CredentialState::MissingKey
204 };
205 }
206 let Some(configured) = config.provider_config_for(provider) else {
207 return CredentialState::MissingKey;
208 };
209 let auth_optional = configured
210 .base_url
211 .as_deref()
212 .is_some_and(crate::config::base_url_uses_local_host)
213 && !api_key_required;
214 if auth_optional {
215 return CredentialState::Local;
216 }
217 let has_auth = (provider == config.api_provider()
218 && crate::config::explicit_cli_api_key_override().is_some())
219 || configured.api_key.as_deref().is_some_and(|value| {
220 crate::config::classify_config_api_key_value(value)
221 == crate::config::ConfigApiKeyValueKind::Literal
222 })
223 || configured
224 .api_key_env
225 .as_deref()
226 .map(str::trim)
227 .filter(|name| !name.is_empty())
228 .is_some_and(|name| {
229 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
230 });
231 return if has_auth {
232 CredentialState::Saved
233 } else {
234 CredentialState::MissingKey
235 };
236 }
237 if crate::config::provider_route_is_keyless_self_hosted(
238 provider,
239 &config.base_url_for_route(provider),
240 ) {
241 return if api_key_required {
242 if crate::config::has_api_key_for(config, provider) {
243 CredentialState::Saved
244 } else {
245 CredentialState::MissingKey
246 }
247 } else {
248 CredentialState::Local
249 };
250 }
251
252 let uses_kimi_imported_token = provider == ApiProvider::Moonshot
253 && official_endpoint
254 && auth_mode
255 .as_deref()
256 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token);
257 if uses_kimi_imported_token {
258 // Kimi remains API-key-only until Codewhale has its own registered
259 // OAuth client identity. Never inspect Kimi CLI storage here.
260 return CredentialState::MissingKey;
261 }
262 if provider == ApiProvider::OpenaiCodex && official_endpoint {
263 return if crate::config::has_api_key_for(config, provider) {
264 CredentialState::Saved
265 } else if provider != config.api_provider()
266 && config.external_credential_read_consent_configured(
267 provider,
268 codewhale_config::ExternalCredentialSource::CodexCli,
269 )
270 {
271 CredentialState::ExternalConsent
272 } else {
273 CredentialState::MissingLogin
274 };
275 }
276 let xai_oauth_selected = provider == ApiProvider::Xai
277 && official_endpoint
278 && auth_mode
279 .as_deref()
280 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth);
281 if xai_oauth_selected {
282 // #5772: `Saved` means a credential was actually found. A surviving
283 // consent record whose file is gone or expired resolves below as
284 // `ExternalConsent` (dormant) or `MissingLogin`, never as `Saved`.
285 return if crate::oauth::credentials_valid(crate::oauth::OAuthProvider::Xai, config)
286 || explicit_provider_credential_present(config, provider)
287 {
288 CredentialState::Saved
289 } else if provider != config.api_provider()
290 && config.external_credential_read_consent_configured(
291 provider,
292 codewhale_config::ExternalCredentialSource::GrokCli,
293 )
294 {
295 CredentialState::ExternalConsent
296 } else {
297 CredentialState::MissingLogin
298 };
299 }
300 if provider == ApiProvider::Xai && explicit_provider_credential_present(config, provider) {
301 return CredentialState::Saved;
302 }
303 if provider == ApiProvider::Xai {
304 return CredentialState::MissingKey;
305 }
306
307 if crate::config::has_api_key_for(config, provider) {
308 CredentialState::Saved
309 } else if matches!(
310 provider,
311 ApiProvider::Deepseek | ApiProvider::DeepseekAnthropic
312 ) && official_endpoint
313 && provider != config.api_provider()
314 && config.external_credential_read_consent_configured(
315 provider,
316 codewhale_config::ExternalCredentialSource::DshCli,
317 )
318 {
319 CredentialState::ExternalConsent
320 } else {
321 CredentialState::MissingKey
322 }
323 }
324
325 fn explicit_provider_credential_present(
326 config: &crate::config::Config,
327 provider: ApiProvider,
328 ) -> bool {
329 (provider == config.api_provider() && crate::config::explicit_cli_api_key_override().is_some())
330 || (!config.provider_uses_custom_endpoint(provider)
331 && provider
332 .env_vars()
333 .iter()
334 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty())))
335 || (config.config_credentials_are_bound_to_provider_endpoint(provider)
336 && config.provider_config_for(provider).is_some_and(|entry| {
337 entry.api_key.as_deref().is_some_and(|value| {
338 crate::config::classify_config_api_key_value(value)
339 == crate::config::ConfigApiKeyValueKind::Literal
340 }) || entry
341 .api_key_env
342 .as_deref()
343 .map(str::trim)
344 .filter(|name| !name.is_empty())
345 .is_some_and(|name| {
346 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
347 })
348 }))
349 }
350
351 /// Validate the configured provider/model/endpoint route without making a
352 /// network request. This is shared by model inventory, `/model`, and Fleet so
353 /// none of them can mark a route selectable when `/provider` would reject it.
354 pub(crate) fn route_is_valid_for_model(
355 config: &crate::config::Config,
356 provider: ApiProvider,
357 model: Option<&str>,
358 ) -> bool {
359 let compatibility_kind =
360 (provider == ApiProvider::DeepseekCN).then_some(codewhale_config::ProviderKind::Deepseek);
361 let Some(kind) = provider.kind().or(compatibility_kind) else {
362 return true;
363 };
364 let configured = config.provider_config_for(provider);
365 let configured_model = model
366 .map(str::trim)
367 .filter(|value| !value.is_empty())
368 .map(str::to_string)
369 .or_else(|| {
370 configured
371 .and_then(|entry| entry.model.as_deref())
372 .map(str::trim)
373 .filter(|value| !value.is_empty())
374 .map(str::to_string)
375 });
376 let active_model = (provider == config.api_provider())
377 .then(|| config.default_model())
378 .filter(|model| !model.trim().is_empty() && !model.eq_ignore_ascii_case("auto"));
379 let request = RouteRequest {
380 explicit_provider: Some(kind),
381 model_selector: configured_model.or(active_model).map(LogicalModelRef::from),
382 saved_provider_model: None,
383 base_url_override: if provider == config.api_provider() {
384 Some(config.active_route_base_url())
385 } else if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
386 config
387 .base_url
388 .as_deref()
389 .map(str::trim)
390 .filter(|value| !value.is_empty())
391 .map(str::to_string)
392 } else {
393 configured
394 .and_then(|entry| entry.base_url.as_deref())
395 .map(str::trim)
396 .filter(|value| !value.is_empty())
397 .map(str::to_string)
398 },
399 limit_overrides: Vec::new(),
400 };
401 RouteResolver::new()
402 .resolve(&request)
403 .is_ok_and(|candidate| candidate.validation().ok)
404 }
405
406 #[derive(Debug, Clone, PartialEq, Eq)]
407 pub(crate) enum LastProviderCheck {
408 Passed,
409 /// A 2xx `/models` response proves reachability only — never model readiness.
410 ModelsEndpointPassed,
411 Failed {
412 category: ErrorCategory,
413 message: String,
414 },
415 }
416
417 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
418 #[serde(rename_all = "snake_case")]
419 pub(crate) enum ResolvedProviderReadiness {
420 MissingKey,
421 MissingLogin,
422 ExternalConsentPendingSelection,
423 SavedUnchecked,
424 ImportedTokenUnchecked,
425 NoAuthUnchecked,
426 LocalUnchecked,
427 Ready,
428 ConnectionCheckedModelUnchecked,
429 SavedLastCheckFailed {
430 category: ErrorCategory,
431 message: String,
432 },
433 InvalidRoute,
434 Legacy,
435 }
436
437 impl ResolvedProviderReadiness {
438 pub(crate) fn label(&self) -> Cow<'static, str> {
439 match self {
440 Self::MissingKey => Cow::Borrowed("missing key"),
441 Self::MissingLogin => Cow::Borrowed("missing login"),
442 Self::ExternalConsentPendingSelection => {
443 Cow::Borrowed("external consent · select to check")
444 }
445 Self::SavedUnchecked => Cow::Borrowed("key saved · not checked"),
446 Self::ImportedTokenUnchecked => Cow::Borrowed("imported token · not checked"),
447 Self::NoAuthUnchecked => Cow::Borrowed("no auth · not checked"),
448 Self::LocalUnchecked => Cow::Borrowed("local · not checked"),
449 Self::Ready => Cow::Borrowed("ready"),
450 Self::ConnectionCheckedModelUnchecked => {
451 Cow::Borrowed("models endpoint 2xx · model not checked")
452 }
453 Self::SavedLastCheckFailed { category, .. } => {
454 Cow::Owned(format!("last check failed ({category})"))
455 }
456 Self::InvalidRoute => Cow::Borrowed("invalid route"),
457 Self::Legacy => Cow::Borrowed("legacy"),
458 }
459 }
460
461 pub(crate) fn detail(&self) -> Option<&str> {
462 match self {
463 Self::SavedLastCheckFailed { message, .. } => Some(message),
464 _ => None,
465 }
466 }
467
468 pub(crate) fn can_attempt(&self) -> bool {
469 matches!(
470 self,
471 Self::SavedUnchecked
472 | Self::NoAuthUnchecked
473 | Self::LocalUnchecked
474 | Self::ImportedTokenUnchecked
475 | Self::Ready
476 | Self::SavedLastCheckFailed { .. }
477 )
478 }
479
480 /// Whether the provider needs an explicit human activation step before it
481 /// can be used. Fleet uses this to turn a dormant external-consent route
482 /// into a real selection without weakening the global readiness boundary.
483 pub(crate) fn requires_explicit_activation(&self) -> bool {
484 matches!(self, Self::ExternalConsentPendingSelection)
485 }
486
487 /// A short, non-sensitive reason this row cannot be activated. `None`
488 /// means the row is either ready or requires explicit activation.
489 pub(crate) fn blocked_reason(&self) -> Option<Cow<'static, str>> {
490 match self {
491 Self::MissingKey => Some(Cow::Borrowed("missing API key")),
492 Self::MissingLogin => Some(Cow::Borrowed("missing login")),
493 Self::InvalidRoute => Some(Cow::Borrowed("invalid route")),
494 Self::Legacy => Some(Cow::Borrowed("legacy route")),
495 Self::ConnectionCheckedModelUnchecked => Some(Cow::Borrowed("model not checked")),
496 Self::SavedLastCheckFailed { message, .. } => Some(Cow::Owned(message.clone())),
497 Self::SavedUnchecked
498 | Self::ImportedTokenUnchecked
499 | Self::NoAuthUnchecked
500 | Self::LocalUnchecked
501 | Self::Ready
502 | Self::ExternalConsentPendingSelection => None,
503 }
504 }
505 }
506
507 #[derive(Debug, Clone, Default)]
508 pub(crate) struct ProviderReadinessSnapshot {
509 checks: Vec<(ProviderRouteIdentity, LastProviderCheck)>,
510 }
511
512 impl ProviderReadinessSnapshot {
513 fn last(&self, identity: &ProviderRouteIdentity) -> Option<&LastProviderCheck> {
514 if let Some(check) = self
515 .checks
516 .iter()
517 .rev()
518 .find_map(|(candidate, check)| (candidate == identity).then_some(check))
519 {
520 return Some(check);
521 }
522 // The auto model route never records under the literal "auto" —
523 // successes and failures are recorded against the concrete model
524 // the router actually ran. Without this fallback every auto-mode
525 // read would report "not checked" forever, even after hundreds of
526 // successful turns on that route.
527 if identity.model != "auto" {
528 return None;
529 }
530 self.checks.iter().rev().find_map(|(candidate, check)| {
531 (candidate.provider == identity.provider
532 && candidate.provider_id == identity.provider_id
533 && candidate.endpoint == identity.endpoint
534 && candidate.auth_class == identity.auth_class)
535 .then_some(check)
536 })
537 }
538
539 pub(crate) fn record_success(
540 &mut self,
541 config: &crate::config::Config,
542 provider: ApiProvider,
543 model: &str,
544 ) {
545 self.replace(
546 route_identity_for_model(config, provider, model),
547 LastProviderCheck::Passed,
548 );
549 }
550
551 /// Records a failed `/models` probe. Unlike [`Self::record_failure`],
552 /// this always stores the result so Test Connection can refresh a
553 /// stuck `not checked` row.
554 pub(crate) fn record_models_probe_failure(
555 &mut self,
556 config: &crate::config::Config,
557 provider: ApiProvider,
558 model: &str,
559 category: ErrorCategory,
560 message: &str,
561 ) {
562 self.replace(
563 route_identity_for_model(config, provider, model),
564 LastProviderCheck::Failed {
565 category,
566 message: sanitize_message(message),
567 },
568 );
569 }
570
571 /// Records a 2xx `/models` probe as connection-checked, never `Ready`.
572 pub(crate) fn record_models_probe_success(
573 &mut self,
574 config: &crate::config::Config,
575 provider: ApiProvider,
576 model: &str,
577 ) {
578 self.replace(
579 route_identity_for_model(config, provider, model),
580 LastProviderCheck::ModelsEndpointPassed,
581 );
582 }
583
584 pub(crate) fn record_failure(
585 &mut self,
586 config: &crate::config::Config,
587 provider: ApiProvider,
588 model: &str,
589 envelope: &ErrorEnvelope,
590 ) {
591 if !provider_owned_failure(envelope) {
592 return;
593 }
594 self.replace(
595 route_identity_for_model(config, provider, model),
596 LastProviderCheck::Failed {
597 category: envelope.category,
598 message: sanitize_message(&envelope.message),
599 },
600 );
601 }
602
603 #[cfg(test)]
604 pub(crate) fn record_failure_message(
605 &mut self,
606 config: &crate::config::Config,
607 provider: ApiProvider,
608 model: &str,
609 category: ErrorCategory,
610 message: &str,
611 ) {
612 self.replace(
613 route_identity_for_model(config, provider, model),
614 LastProviderCheck::Failed {
615 category,
616 message: sanitize_message(message),
617 },
618 );
619 }
620
621 fn replace(&mut self, identity: ProviderRouteIdentity, check: LastProviderCheck) {
622 self.checks.retain(|(candidate, _)| candidate != &identity);
623 self.checks.push((identity, check));
624 }
625 }
626
627 pub(crate) fn resolve_with_identity(
628 identity: &ProviderRouteIdentity,
629 credentials: CredentialState,
630 route_ok: bool,
631 checks: &ProviderReadinessSnapshot,
632 ) -> ResolvedProviderReadiness {
633 if !route_ok {
634 return ResolvedProviderReadiness::InvalidRoute;
635 }
636 match credentials {
637 CredentialState::Legacy => ResolvedProviderReadiness::Legacy,
638 CredentialState::MissingKey => ResolvedProviderReadiness::MissingKey,
639 CredentialState::MissingLogin => ResolvedProviderReadiness::MissingLogin,
640 CredentialState::ExternalConsent => match checks.last(identity) {
641 Some(LastProviderCheck::Passed) => ResolvedProviderReadiness::Ready,
642 Some(LastProviderCheck::ModelsEndpointPassed) => {
643 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
644 }
645 Some(LastProviderCheck::Failed { category, message }) => {
646 ResolvedProviderReadiness::SavedLastCheckFailed {
647 category: *category,
648 message: message.clone(),
649 }
650 }
651 None => ResolvedProviderReadiness::ExternalConsentPendingSelection,
652 },
653 CredentialState::Saved
654 | CredentialState::ImportedToken
655 | CredentialState::NoAuth
656 | CredentialState::Local => match checks.last(identity) {
657 Some(LastProviderCheck::Passed) => ResolvedProviderReadiness::Ready,
658 Some(LastProviderCheck::ModelsEndpointPassed) => {
659 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
660 }
661 Some(LastProviderCheck::Failed { category, message }) => {
662 ResolvedProviderReadiness::SavedLastCheckFailed {
663 category: *category,
664 message: message.clone(),
665 }
666 }
667 None if credentials == CredentialState::NoAuth => {
668 ResolvedProviderReadiness::NoAuthUnchecked
669 }
670 None if credentials == CredentialState::Local => {
671 ResolvedProviderReadiness::LocalUnchecked
672 }
673 None if credentials == CredentialState::ImportedToken => {
674 ResolvedProviderReadiness::ImportedTokenUnchecked
675 }
676 None => ResolvedProviderReadiness::SavedUnchecked,
677 },
678 }
679 }
680
681 pub(crate) fn resolve_for_model(
682 config: &crate::config::Config,
683 provider: ApiProvider,
684 model: &str,
685 checks: &ProviderReadinessSnapshot,
686 ) -> ResolvedProviderReadiness {
687 resolve_with_identity(
688 &route_identity_for_model(config, provider, model),
689 credential_state_for_provider(config, provider),
690 route_is_valid_for_model(config, provider, Some(model)),
691 checks,
692 )
693 }
694
695 fn provider_owned_failure(envelope: &ErrorEnvelope) -> bool {
696 matches!(
697 envelope.category,
698 ErrorCategory::Network
699 | ErrorCategory::Authentication
700 | ErrorCategory::Authorization
701 | ErrorCategory::RateLimit
702 | ErrorCategory::Timeout
703 )
704 }
705
706 fn sanitize_message(message: &str) -> String {
707 crate::utils::truncate_with_ellipsis(message.trim(), 120, "…")
708 }
709
710 #[cfg(test)]
711 mod tests {
712 use super::*;
713 use crate::error_taxonomy::ErrorSeverity;
714
715 fn resolve_test_route(
716 config: &crate::config::Config,
717 provider: ApiProvider,
718 model: &str,
719 credentials: CredentialState,
720 route_ok: bool,
721 checks: &ProviderReadinessSnapshot,
722 ) -> ResolvedProviderReadiness {
723 resolve_with_identity(
724 &route_identity_for_model(config, provider, model),
725 credentials,
726 route_ok,
727 checks,
728 )
729 }
730
731 #[test]
732 fn saved_credentials_are_never_ready_without_observed_success() {
733 let config = crate::config::Config::default();
734 let checks = ProviderReadinessSnapshot::default();
735 assert_eq!(
736 resolve_test_route(
737 &config,
738 ApiProvider::Deepseek,
739 "deepseek-v4-pro",
740 CredentialState::Saved,
741 true,
742 &checks,
743 ),
744 ResolvedProviderReadiness::SavedUnchecked
745 );
746 }
747
748 #[test]
749 fn deepseek_cn_compatibility_alias_uses_real_key_readiness() {
750 let _lock = crate::test_support::lock_test_env();
751 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
752 let missing = crate::config::Config {
753 provider: Some("deepseek-cn".to_string()),
754 ..Default::default()
755 };
756 assert_eq!(
757 credential_state_for_provider(&missing, ApiProvider::DeepseekCN),
758 CredentialState::MissingKey
759 );
760
761 let configured = crate::config::Config {
762 provider: Some("deepseek-cn".to_string()),
763 providers: Some(crate::config::ProvidersConfig {
764 deepseek_cn: crate::config::ProviderConfig {
765 api_key: Some("deepseek-cn-test-key".to_string()),
766 ..Default::default()
767 },
768 ..Default::default()
769 }),
770 ..Default::default()
771 };
772 assert_eq!(
773 credential_state_for_provider(&configured, ApiProvider::DeepseekCN),
774 CredentialState::Saved
775 );
776 }
777
778 #[test]
779 fn custom_readiness_identity_preserves_case_sensitive_route_parts() {
780 let custom = std::collections::HashMap::from([
781 (
782 "CUSTOM".to_string(),
783 crate::config::ProviderConfig {
784 kind: Some("openai-compatible".to_string()),
785 base_url: Some("https://example.test/TenantA/v1".to_string()),
786 model: Some("Vendor/ModelA".to_string()),
787 api_key: Some("test-key-a".to_string()),
788 ..Default::default()
789 },
790 ),
791 (
792 "custom".to_string(),
793 crate::config::ProviderConfig {
794 kind: Some("openai-compatible".to_string()),
795 base_url: Some("https://example.test/tenanta/v1".to_string()),
796 model: Some("vendor/modela".to_string()),
797 api_key: Some("test-key-b".to_string()),
798 ..Default::default()
799 },
800 ),
801 ]);
802 let upper = crate::config::Config {
803 provider: Some("CUSTOM".to_string()),
804 providers: Some(crate::config::ProvidersConfig {
805 custom: custom.clone(),
806 ..Default::default()
807 }),
808 ..Default::default()
809 };
810 let lower = crate::config::Config {
811 provider: Some("custom".to_string()),
812 providers: Some(crate::config::ProvidersConfig {
813 custom,
814 ..Default::default()
815 }),
816 ..Default::default()
817 };
818 let upper_identity = route_identity_for_model(&upper, ApiProvider::Custom, "Vendor/ModelA");
819 let lower_identity = route_identity_for_model(&lower, ApiProvider::Custom, "vendor/modela");
820
821 assert_ne!(upper_identity, lower_identity);
822 assert_eq!(upper_identity.provider_id, "CUSTOM");
823 assert_eq!(upper_identity.endpoint, "https://example.test/TenantA/v1");
824 assert_eq!(upper_identity.model, "Vendor/ModelA");
825
826 let mut checks = ProviderReadinessSnapshot::default();
827 checks.record_success(&upper, ApiProvider::Custom, "Vendor/ModelA");
828 assert_eq!(
829 resolve_for_model(&lower, ApiProvider::Custom, "vendor/modela", &checks),
830 ResolvedProviderReadiness::SavedUnchecked
831 );
832 }
833
834 #[test]
835 fn external_consent_with_passed_check_becomes_ready() {
836 let config = crate::config::Config::default();
837 let mut checks = ProviderReadinessSnapshot::default();
838 checks.record_success(
839 &config,
840 ApiProvider::OpenaiCodex,
841 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
842 );
843 assert_eq!(
844 resolve_test_route(
845 &config,
846 ApiProvider::OpenaiCodex,
847 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
848 CredentialState::ExternalConsent,
849 true,
850 &checks,
851 ),
852 ResolvedProviderReadiness::Ready
853 );
854 }
855
856 #[test]
857 fn external_consent_with_failed_check_surfaces_reason() {
858 let config = crate::config::Config::default();
859 let mut checks = ProviderReadinessSnapshot::default();
860 checks.record_failure_message(
861 &config,
862 ApiProvider::Xai,
863 "grok-4.5",
864 ErrorCategory::Authentication,
865 "consent revoked",
866 );
867 assert!(
868 matches!(
869 resolve_test_route(
870 &config,
871 ApiProvider::Xai,
872 "grok-4.5",
873 CredentialState::ExternalConsent,
874 true,
875 &checks,
876 ),
877 ResolvedProviderReadiness::SavedLastCheckFailed { category, .. }
878 if category == ErrorCategory::Authentication
879 ),
880 "failed activation should surface the sanitized reason"
881 );
882 }
883
884 #[test]
885 fn external_consent_without_check_stays_pending_selection() {
886 let config = crate::config::Config::default();
887 let checks = ProviderReadinessSnapshot::default();
888 assert_eq!(
889 resolve_test_route(
890 &config,
891 ApiProvider::OpenaiCodex,
892 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
893 CredentialState::ExternalConsent,
894 true,
895 &checks,
896 ),
897 ResolvedProviderReadiness::ExternalConsentPendingSelection
898 );
899 }
900
901 #[test]
902 fn models_probe_success_marks_connection_checked_not_ready() {
903 let config = crate::config::Config::default();
904 let mut checks = ProviderReadinessSnapshot::default();
905 checks.record_models_probe_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro");
906 assert_eq!(
907 resolve_test_route(
908 &config,
909 ApiProvider::Deepseek,
910 "deepseek-v4-pro",
911 CredentialState::Saved,
912 true,
913 &checks,
914 ),
915 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
916 );
917 assert_eq!(
918 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked.label(),
919 "models endpoint 2xx · model not checked"
920 );
921 assert!(!ResolvedProviderReadiness::ConnectionCheckedModelUnchecked.can_attempt());
922 }
923
924 #[test]
925 fn success_and_provider_failure_replace_session_evidence() {
926 let config = crate::config::Config::default();
927 let mut checks = ProviderReadinessSnapshot::default();
928 checks.record_success(&config, ApiProvider::Zai, "glm-5.2");
929 assert_eq!(
930 resolve_test_route(
931 &config,
932 ApiProvider::Zai,
933 "glm-5.2",
934 CredentialState::Saved,
935 true,
936 &checks,
937 ),
938 ResolvedProviderReadiness::Ready
939 );
940
941 checks.record_failure(
942 &config,
943 ApiProvider::Zai,
944 "glm-5.2",
945 &ErrorEnvelope::new(
946 ErrorCategory::Authentication,
947 ErrorSeverity::Error,
948 false,
949 "auth_failed",
950 "token rejected",
951 ),
952 );
953 let resolved = resolve_test_route(
954 &config,
955 ApiProvider::Zai,
956 "glm-5.2",
957 CredentialState::Saved,
958 true,
959 &checks,
960 );
961 assert!(matches!(
962 resolved,
963 ResolvedProviderReadiness::SavedLastCheckFailed {
964 category: ErrorCategory::Authentication,
965 ..
966 }
967 ));
968 assert!(resolved.can_attempt());
969 }
970
971 #[test]
972 fn tool_failures_do_not_poison_provider_health() {
973 let config = crate::config::Config::default();
974 let mut checks = ProviderReadinessSnapshot::default();
975 checks.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro");
976 checks.record_failure(
977 &config,
978 ApiProvider::Deepseek,
979 "deepseek-v4-pro",
980 &ErrorEnvelope::new(
981 ErrorCategory::Tool,
982 ErrorSeverity::Error,
983 false,
984 "tool_failed",
985 "shell failed",
986 ),
987 );
988 assert!(matches!(
989 resolve_test_route(
990 &config,
991 ApiProvider::Deepseek,
992 "deepseek-v4-pro",
993 CredentialState::Saved,
994 true,
995 &checks,
996 ),
997 ResolvedProviderReadiness::Ready
998 ));
999 }
1000
1001 #[test]
1002 fn route_and_missing_auth_states_dominate_health() {
1003 let config = crate::config::Config {
1004 provider: Some("openai-codex".to_string()),
1005 ..Default::default()
1006 };
1007 let mut checks = ProviderReadinessSnapshot::default();
1008 checks.record_success(&config, ApiProvider::OpenaiCodex, "gpt-5.5");
1009 assert_eq!(
1010 resolve_test_route(
1011 &config,
1012 ApiProvider::OpenaiCodex,
1013 "gpt-5.5",
1014 CredentialState::MissingLogin,
1015 true,
1016 &checks
1017 ),
1018 ResolvedProviderReadiness::MissingLogin
1019 );
1020 assert_eq!(
1021 resolve_test_route(
1022 &config,
1023 ApiProvider::OpenaiCodex,
1024 "gpt-5.5",
1025 CredentialState::Saved,
1026 false,
1027 &checks
1028 ),
1029 ResolvedProviderReadiness::InvalidRoute
1030 );
1031 }
1032
1033 #[test]
1034 fn api_key_success_does_not_verify_new_xai_oauth_route() {
1035 let _lock = crate::test_support::lock_test_env();
1036 let temp = tempfile::tempdir().expect("oauth fixture root");
1037 let oauth_path = temp.path().join("grok-auth.json");
1038 std::fs::write(
1039 &oauth_path,
1040 serde_json::to_vec(&serde_json::json!({
1041 "test-scope": {
1042 "key": "expired-access-token",
1043 "refresh_token": "saved-refresh-token",
1044 "expires_at": "2000-01-01T00:00:00Z",
1045 "auth_mode": "oidc"
1046 }
1047 }))
1048 .expect("oauth json"),
1049 )
1050 .expect("oauth fixture");
1051 let _oauth_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &oauth_path);
1052
1053 let api_key_config = crate::config::Config {
1054 provider: Some("xai".to_string()),
1055 providers: Some(crate::config::ProvidersConfig {
1056 xai: crate::config::ProviderConfig {
1057 api_key: Some("xai-test-key".to_string()),
1058 auth_mode: Some("api_key".to_string()),
1059 ..Default::default()
1060 },
1061 ..Default::default()
1062 }),
1063 ..Default::default()
1064 };
1065 let mut checks = ProviderReadinessSnapshot::default();
1066 let api_key_model = api_key_config.default_model();
1067 checks.record_success(&api_key_config, ApiProvider::Xai, &api_key_model);
1068
1069 let mut oauth_config = api_key_config;
1070 oauth_config
1071 .providers
1072 .as_mut()
1073 .expect("providers")
1074 .xai
1075 .auth_mode = Some("oauth".to_string());
1076 assert_eq!(
1077 credential_state_for_provider(&oauth_config, ApiProvider::Xai),
1078 CredentialState::Saved,
1079 "fixture must have structurally valid OAuth material"
1080 );
1081 let model = oauth_config.default_model();
1082 assert_eq!(
1083 resolve_for_model(&oauth_config, ApiProvider::Xai, &model, &checks),
1084 ResolvedProviderReadiness::SavedUnchecked,
1085 "API-key evidence must not cross the auth-class boundary"
1086 );
1087 }
1088
1089 #[test]
1090 fn observed_success_is_scoped_to_exact_model_endpoint_and_custom_provider() {
1091 let deepseek = crate::config::Config {
1092 api_key: Some("deepseek-test-key".to_string()),
1093 ..Default::default()
1094 };
1095 let mut checks = ProviderReadinessSnapshot::default();
1096 checks.record_success(&deepseek, ApiProvider::Deepseek, "deepseek-v4-pro");
1097 assert_eq!(
1098 resolve_for_model(
1099 &deepseek,
1100 ApiProvider::Deepseek,
1101 "deepseek-v4-flash",
1102 &checks,
1103 ),
1104 ResolvedProviderReadiness::SavedUnchecked,
1105 "one model entitlement must not verify a sibling model"
1106 );
1107
1108 let custom_config = |id: &str, endpoint: &str| crate::config::Config {
1109 provider: Some(id.to_string()),
1110 providers: Some(crate::config::ProvidersConfig {
1111 custom: std::collections::HashMap::from([(
1112 id.to_string(),
1113 crate::config::ProviderConfig {
1114 kind: Some("openai-compatible".to_string()),
1115 base_url: Some(endpoint.to_string()),
1116 model: Some("private-coder".to_string()),
1117 api_key: Some("custom-test-key".to_string()),
1118 ..Default::default()
1119 },
1120 )]),
1121 ..Default::default()
1122 }),
1123 ..Default::default()
1124 };
1125 let alpha = custom_config("alpha", "https://alpha.example/v1");
1126 checks.record_success(&alpha, ApiProvider::Custom, "private-coder");
1127
1128 let beta = custom_config("beta", "https://alpha.example/v1");
1129 assert_eq!(
1130 resolve_for_model(&beta, ApiProvider::Custom, "private-coder", &checks),
1131 ResolvedProviderReadiness::SavedUnchecked,
1132 "named custom providers must not share observed health"
1133 );
1134
1135 let alpha_moved = custom_config("alpha", "https://other.example/v1");
1136 assert_eq!(
1137 resolve_for_model(&alpha_moved, ApiProvider::Custom, "private-coder", &checks,),
1138 ResolvedProviderReadiness::SavedUnchecked,
1139 "changing endpoints must invalidate observed health"
1140 );
1141 }
1142
1143 #[test]
1144 fn auto_model_readiness_follows_the_route_not_the_literal_identity() {
1145 let config = crate::config::Config {
1146 api_key: Some("test-key".to_string()),
1147 ..Default::default()
1148 };
1149 let mut checks = ProviderReadinessSnapshot::default();
1150
1151 // Before any observed turn, auto honestly reports unchecked.
1152 assert_eq!(
1153 resolve_for_model(&config, ApiProvider::Deepseek, "auto", &checks),
1154 ResolvedProviderReadiness::SavedUnchecked,
1155 "auto with no observed history must stay unchecked"
1156 );
1157
1158 // A successful concrete-model turn on the route verifies auto too —
1159 // the router picks the model, so per-model scoping cannot apply.
1160 checks.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-flash");
1161 assert_eq!(
1162 resolve_for_model(&config, ApiProvider::Deepseek, "auto", &checks),
1163 ResolvedProviderReadiness::Ready,
1164 "a passed turn on the route must clear auto's not-checked badge"
1165 );
1166
1167 // A later failure on the same route must surface for auto as well.
1168 checks.record_failure_message(
1169 &config,
1170 ApiProvider::Deepseek,
1171 "deepseek-v4-pro",
1172 crate::error_taxonomy::ErrorCategory::Authentication,
1173 "401 unauthorized",
1174 );
1175 assert!(
1176 matches!(
1177 resolve_for_model(&config, ApiProvider::Deepseek, "auto", &checks),
1178 ResolvedProviderReadiness::SavedLastCheckFailed { .. }
1179 ),
1180 "the most recent route check must win for auto, including failures"
1181 );
1182 }
1183
1184 #[test]
1185 fn auto_readiness_does_not_leak_across_providers_or_endpoints() {
1186 let custom_config = |id: &str, endpoint: &str| crate::config::Config {
1187 provider: Some(id.to_string()),
1188 providers: Some(crate::config::ProvidersConfig {
1189 custom: std::collections::HashMap::from([(
1190 id.to_string(),
1191 crate::config::ProviderConfig {
1192 kind: Some("openai-compatible".to_string()),
1193 base_url: Some(endpoint.to_string()),
1194 model: Some("private-coder".to_string()),
1195 api_key: Some("test-key".to_string()),
1196 ..Default::default()
1197 },
1198 )]),
1199 ..Default::default()
1200 }),
1201 ..Default::default()
1202 };
1203 let mut checks = ProviderReadinessSnapshot::default();
1204 let alpha = custom_config("alpha", "https://alpha.example/v1");
1205 checks.record_success(&alpha, ApiProvider::Custom, "private-coder");
1206
1207 // Same endpoint, different named provider: no leak.
1208 let beta = custom_config("beta", "https://alpha.example/v1");
1209 assert_eq!(
1210 resolve_for_model(&beta, ApiProvider::Custom, "auto", &checks),
1211 ResolvedProviderReadiness::SavedUnchecked,
1212 "auto fallback is scoped to the route, not the workspace"
1213 );
1214
1215 // Same named provider, different endpoint: no leak.
1216 let alpha_moved = custom_config("alpha", "https://other.example/v1");
1217 assert_eq!(
1218 resolve_for_model(&alpha_moved, ApiProvider::Custom, "auto", &checks),
1219 ResolvedProviderReadiness::SavedUnchecked,
1220 "changing endpoints must invalidate the auto fallback too"
1221 );
1222 }
1223
1224 #[test]
1225 fn disabled_external_imports_are_not_probed_by_readiness() {
1226 let _lock = crate::test_support::lock_test_env();
1227 let temp = tempfile::tempdir().expect("oauth fixture root");
1228 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1229 let kimi_home = temp.path().join("kimi");
1230 std::fs::create_dir_all(kimi_home.join("credentials")).expect("kimi credentials dir");
1231 std::fs::write(kimi_home.join("credentials/kimi-code.json"), "{not-json")
1232 .expect("malformed kimi fixture");
1233 let _kimi_home = crate::test_support::EnvVarGuard::set(
1234 "KIMI_CODE_HOME",
1235 kimi_home.to_str().expect("utf8 path"),
1236 );
1237 let grok_path = temp.path().join("grok-auth.json");
1238 std::fs::write(&grok_path, "{}").expect("empty grok fixture");
1239 let _grok_path = crate::test_support::EnvVarGuard::set(
1240 "GROK_AUTH_PATH",
1241 grok_path.to_str().expect("utf8 path"),
1242 );
1243
1244 let config = crate::config::Config {
1245 providers: Some(crate::config::ProvidersConfig {
1246 moonshot: crate::config::ProviderConfig {
1247 auth_mode: Some("kimi_oauth".to_string()),
1248 ..Default::default()
1249 },
1250 xai: crate::config::ProviderConfig {
1251 auth_mode: Some("oauth".to_string()),
1252 ..Default::default()
1253 },
1254 ..Default::default()
1255 }),
1256 ..Default::default()
1257 };
1258
1259 crate::external_credentials::reset_side_effect_trap();
1260 assert_eq!(
1261 credential_state_for_provider(&config, ApiProvider::Moonshot),
1262 CredentialState::MissingKey,
1263 "an unusable Kimi import must recover through the supported API-key route"
1264 );
1265 assert_eq!(
1266 credential_state_for_provider(&config, ApiProvider::Xai),
1267 CredentialState::MissingLogin
1268 );
1269 assert_eq!(
1270 crate::external_credentials::side_effect_trap_counts(),
1271 (0, 0),
1272 "readiness must not inspect external OAuth files without consent"
1273 );
1274 assert_eq!(
1275 std::fs::read_to_string(kimi_home.join("credentials/kimi-code.json"))
1276 .expect("Kimi fixture unchanged"),
1277 "{not-json"
1278 );
1279 assert_eq!(
1280 std::fs::read_to_string(&grok_path).expect("Grok fixture unchanged"),
1281 "{}"
1282 );
1283
1284 let api_key_config = crate::config::Config {
1285 providers: Some(crate::config::ProvidersConfig {
1286 xai: crate::config::ProviderConfig {
1287 api_key: Some("explicit-xai-key".to_string()),
1288 ..Default::default()
1289 },
1290 ..Default::default()
1291 }),
1292 ..Default::default()
1293 };
1294 assert_eq!(
1295 credential_state_for_provider(&api_key_config, ApiProvider::Xai),
1296 CredentialState::Saved,
1297 "an unrelated stale Grok OAuth file must not shadow an explicit xAI API key"
1298 );
1299 assert_eq!(
1300 credential_state_for_provider(&crate::config::Config::default(), ApiProvider::Xai),
1301 CredentialState::MissingKey,
1302 "a Grok file is not active until xAI OAuth is selected in config"
1303 );
1304 let stale_root_config = crate::config::Config {
1305 provider: Some("xai".to_string()),
1306 api_key: Some("legacy-deepseek-root-key".to_string()),
1307 ..Default::default()
1308 };
1309 assert_eq!(
1310 credential_state_for_provider(&stale_root_config, ApiProvider::Xai),
1311 CredentialState::MissingKey,
1312 "the legacy root DeepSeek key is not an xAI credential"
1313 );
1314
1315 let _cli_source = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
1316 let _cli_key =
1317 crate::test_support::EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
1318 let cli_config = crate::config::Config {
1319 provider: Some("xai".to_string()),
1320 ..Default::default()
1321 };
1322 assert_eq!(
1323 credential_state_for_provider(&cli_config, ApiProvider::Xai),
1324 CredentialState::Saved,
1325 "the source-marked CLI override is valid for the active xAI provider"
1326 );
1327 }
1328
1329 #[test]
1330 fn custom_provider_env_and_local_no_auth_states_match_runtime() {
1331 let _lock = crate::test_support::lock_test_env();
1332 let _custom_key = crate::test_support::EnvVarGuard::set("ACME_CUSTOM_KEY", "custom-secret");
1333 let remote = crate::config::Config {
1334 provider: Some("acme".to_string()),
1335 providers: Some(crate::config::ProvidersConfig {
1336 custom: std::collections::HashMap::from([(
1337 "acme".to_string(),
1338 crate::config::ProviderConfig {
1339 kind: Some("openai-compatible".to_string()),
1340 base_url: Some("https://api.acme.test/v1".to_string()),
1341 model: Some("acme-coder".to_string()),
1342 api_key_env: Some("ACME_CUSTOM_KEY".to_string()),
1343 ..Default::default()
1344 },
1345 )]),
1346 ..Default::default()
1347 }),
1348 ..Default::default()
1349 };
1350 assert_eq!(
1351 credential_state_for_provider(&remote, ApiProvider::Custom),
1352 CredentialState::Saved
1353 );
1354
1355 let local = crate::config::Config {
1356 provider: Some("local-acme".to_string()),
1357 providers: Some(crate::config::ProvidersConfig {
1358 custom: std::collections::HashMap::from([(
1359 "local-acme".to_string(),
1360 crate::config::ProviderConfig {
1361 kind: Some("openai-compatible".to_string()),
1362 base_url: Some("http://127.0.0.1:8080/v1".to_string()),
1363 model: Some("local-model".to_string()),
1364 auth_mode: Some("none".to_string()),
1365 ..Default::default()
1366 },
1367 )]),
1368 ..Default::default()
1369 }),
1370 ..Default::default()
1371 };
1372 assert_eq!(
1373 credential_state_for_provider(&local, ApiProvider::Custom),
1374 CredentialState::NoAuth
1375 );
1376 assert_eq!(
1377 resolve_for_model(
1378 &local,
1379 ApiProvider::Custom,
1380 "local-model",
1381 &ProviderReadinessSnapshot::default(),
1382 ),
1383 ResolvedProviderReadiness::NoAuthUnchecked
1384 );
1385 }
1386
1387 #[test]
1388 fn no_auth_has_distinct_readiness_and_cache_identity_from_implicit_local() {
1389 let local = crate::config::Config {
1390 provider: Some("vllm".to_string()),
1391 providers: Some(crate::config::ProvidersConfig {
1392 vllm: crate::config::ProviderConfig {
1393 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
1394 model: Some("local-model".to_string()),
1395 ..Default::default()
1396 },
1397 ..Default::default()
1398 }),
1399 ..Default::default()
1400 };
1401 let mut no_auth = local.clone();
1402 no_auth
1403 .providers
1404 .as_mut()
1405 .expect("providers")
1406 .vllm
1407 .auth_mode = Some("no-auth".to_string());
1408
1409 assert_eq!(
1410 credential_state_for_provider(&local, ApiProvider::Vllm),
1411 CredentialState::Local
1412 );
1413 assert_eq!(
1414 credential_state_for_provider(&no_auth, ApiProvider::Vllm),
1415 CredentialState::NoAuth
1416 );
1417 assert_eq!(
1418 auth_class_for_provider(&local, ApiProvider::Vllm),
1419 ProviderAuthClass::Local
1420 );
1421 assert_eq!(
1422 auth_class_for_provider(&no_auth, ApiProvider::Vllm),
1423 ProviderAuthClass::NoAuth
1424 );
1425
1426 let mut checks = ProviderReadinessSnapshot::default();
1427 checks.record_success(&local, ApiProvider::Vllm, "local-model");
1428 assert_eq!(
1429 resolve_for_model(&no_auth, ApiProvider::Vllm, "local-model", &checks),
1430 ResolvedProviderReadiness::NoAuthUnchecked,
1431 "implicit-local success must not verify an explicitly no-auth route"
1432 );
1433 assert!(ResolvedProviderReadiness::NoAuthUnchecked.can_attempt());
1434 assert_eq!(
1435 ResolvedProviderReadiness::NoAuthUnchecked.label(),
1436 "no auth · not checked"
1437 );
1438 }
1439
1440 #[test]
1441 fn ollama_readiness_distinguishes_local_from_cloud_credentials() {
1442 let _lock = crate::test_support::lock_test_env();
1443 let temp = tempfile::tempdir().expect("isolated credential home");
1444 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1445 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1446 let _ollama_cloud_key = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
1447 let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
1448 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1449 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1450
1451 let local = crate::config::Config {
1452 provider: Some("ollama".to_string()),
1453 ..Default::default()
1454 };
1455 assert_eq!(
1456 credential_state_for_provider(&local, ApiProvider::Ollama),
1457 CredentialState::Local
1458 );
1459 assert_eq!(
1460 auth_class_for_provider(&local, ApiProvider::Ollama),
1461 ProviderAuthClass::Local
1462 );
1463
1464 let mut cloud = crate::config::Config {
1465 provider: Some("ollama".to_string()),
1466 providers: Some(crate::config::ProvidersConfig {
1467 ollama: crate::config::ProviderConfig {
1468 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
1469 ..Default::default()
1470 },
1471 ..Default::default()
1472 }),
1473 ..Default::default()
1474 };
1475 assert_eq!(cloud.api_provider(), ApiProvider::OllamaCloud);
1476 assert_eq!(
1477 credential_state_for_provider(&cloud, ApiProvider::OllamaCloud),
1478 CredentialState::MissingKey
1479 );
1480 assert_eq!(
1481 auth_class_for_provider(&cloud, ApiProvider::OllamaCloud),
1482 ProviderAuthClass::ApiKey
1483 );
1484
1485 cloud.providers.as_mut().expect("providers").ollama.api_key =
1486 Some("ollama-cloud-key".to_string());
1487 assert_eq!(
1488 credential_state_for_provider(&cloud, ApiProvider::OllamaCloud),
1489 CredentialState::Saved
1490 );
1491 }
1492
1493 #[test]
1494 fn explicit_api_key_mode_on_loopback_requires_a_real_credential() {
1495 let _lock = crate::test_support::lock_test_env();
1496 let temp = tempfile::tempdir().expect("isolated credential home");
1497 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1498 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1499 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
1500 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1501 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1502
1503 let missing = crate::config::Config {
1504 provider: Some("vllm".to_string()),
1505 providers: Some(crate::config::ProvidersConfig {
1506 vllm: crate::config::ProviderConfig {
1507 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
1508 model: Some("local-model".to_string()),
1509 auth_mode: Some("api_key".to_string()),
1510 ..Default::default()
1511 },
1512 ..Default::default()
1513 }),
1514 ..Default::default()
1515 };
1516 assert_eq!(
1517 credential_state_for_provider(&missing, ApiProvider::Vllm),
1518 CredentialState::MissingKey
1519 );
1520 assert!(missing.active_route_api_key().is_err());
1521
1522 let mut configured = missing.clone();
1523 configured
1524 .providers
1525 .as_mut()
1526 .expect("providers")
1527 .vllm
1528 .api_key = Some("protected-local-key".to_string());
1529 assert_eq!(
1530 credential_state_for_provider(&configured, ApiProvider::Vllm),
1531 CredentialState::Saved
1532 );
1533 assert_eq!(
1534 configured.active_route_api_key().expect("configured key"),
1535 "protected-local-key"
1536 );
1537
1538 let named_custom = crate::config::Config {
1539 provider: Some("protected-local".to_string()),
1540 providers: Some(crate::config::ProvidersConfig {
1541 custom: std::collections::HashMap::from([(
1542 "protected-local".to_string(),
1543 crate::config::ProviderConfig {
1544 kind: Some("openai-compatible".to_string()),
1545 base_url: Some("http://127.0.0.1:9000/v1".to_string()),
1546 model: Some("private-model".to_string()),
1547 auth_mode: Some("bearer".to_string()),
1548 ..Default::default()
1549 },
1550 )]),
1551 ..Default::default()
1552 }),
1553 ..Default::default()
1554 };
1555 assert_eq!(
1556 credential_state_for_provider(&named_custom, ApiProvider::Custom),
1557 CredentialState::MissingKey
1558 );
1559 assert!(named_custom.active_route_api_key().is_err());
1560 }
1561
1562 #[test]
1563 fn provider_auth_metadata_is_not_a_runtime_credential() {
1564 let _lock = crate::test_support::lock_test_env();
1565 let temp = tempfile::tempdir().expect("isolated credential home");
1566 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1567 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1568 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
1569 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1570 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1571 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1572
1573 let command = crate::config::Config {
1574 provider: Some("openai".to_string()),
1575 providers: Some(crate::config::ProvidersConfig {
1576 openai: crate::config::ProviderConfig {
1577 auth: Some(codewhale_config::ProviderAuthSourceToml {
1578 source: codewhale_config::AuthSourceKind::Command,
1579 command: vec!["secret-tool".to_string(), "lookup".to_string()],
1580 timeout_ms: Some(2_000),
1581 secret_id: None,
1582 }),
1583 ..Default::default()
1584 },
1585 ..Default::default()
1586 }),
1587 ..Default::default()
1588 };
1589 assert_eq!(
1590 credential_state_for_provider(&command, ApiProvider::Openai),
1591 CredentialState::MissingKey
1592 );
1593
1594 let secret = crate::config::Config {
1595 provider: Some("xai".to_string()),
1596 providers: Some(crate::config::ProvidersConfig {
1597 xai: crate::config::ProviderConfig {
1598 auth: Some(codewhale_config::ProviderAuthSourceToml {
1599 source: codewhale_config::AuthSourceKind::Secret,
1600 command: Vec::new(),
1601 timeout_ms: None,
1602 secret_id: Some("codewhale/xai".to_string()),
1603 }),
1604 ..Default::default()
1605 },
1606 ..Default::default()
1607 }),
1608 ..Default::default()
1609 };
1610 assert_eq!(
1611 credential_state_for_provider(&secret, ApiProvider::Xai),
1612 CredentialState::MissingKey
1613 );
1614 }
1615
1616 #[test]
1617 fn oauth_readiness_is_limited_to_official_endpoints() {
1618 let _lock = crate::test_support::lock_test_env();
1619 let temp = tempfile::tempdir().expect("isolated oauth home");
1620 let missing_grok_auth = temp.path().join("missing-grok-auth.json");
1621 let _grok_auth =
1622 crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &missing_grok_auth);
1623 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1624 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
1625 let _legacy_codex_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
1626
1627 let custom_xai = crate::config::Config {
1628 provider: Some("xai".to_string()),
1629 providers: Some(crate::config::ProvidersConfig {
1630 xai: crate::config::ProviderConfig {
1631 base_url: Some("https://gateway.example.test/v1".to_string()),
1632 auth_mode: Some("oauth".to_string()),
1633 ..Default::default()
1634 },
1635 ..Default::default()
1636 }),
1637 ..Default::default()
1638 };
1639 assert_eq!(
1640 auth_class_for_provider(&custom_xai, ApiProvider::Xai),
1641 ProviderAuthClass::ApiKey
1642 );
1643 assert_eq!(
1644 credential_state_for_provider(&custom_xai, ApiProvider::Xai),
1645 CredentialState::MissingKey
1646 );
1647
1648 let official_xai = crate::config::Config {
1649 provider: Some("xai".to_string()),
1650 providers: Some(crate::config::ProvidersConfig {
1651 xai: crate::config::ProviderConfig {
1652 auth_mode: Some("oauth".to_string()),
1653 ..Default::default()
1654 },
1655 ..Default::default()
1656 }),
1657 ..Default::default()
1658 };
1659 assert_eq!(
1660 auth_class_for_provider(&official_xai, ApiProvider::Xai),
1661 ProviderAuthClass::OAuth
1662 );
1663 assert_eq!(
1664 credential_state_for_provider(&official_xai, ApiProvider::Xai),
1665 CredentialState::MissingLogin
1666 );
1667
1668 let custom_codex = crate::config::Config {
1669 provider: Some("openai-codex".to_string()),
1670 providers: Some(crate::config::ProvidersConfig {
1671 openai_codex: crate::config::ProviderConfig {
1672 base_url: Some("https://gateway.example.test/v1".to_string()),
1673 ..Default::default()
1674 },
1675 ..Default::default()
1676 }),
1677 ..Default::default()
1678 };
1679 assert_eq!(
1680 auth_class_for_provider(&custom_codex, ApiProvider::OpenaiCodex),
1681 ProviderAuthClass::ApiKey
1682 );
1683 assert_eq!(
1684 credential_state_for_provider(&custom_codex, ApiProvider::OpenaiCodex),
1685 CredentialState::MissingKey
1686 );
1687 }
1688
1689 #[test]
1690 fn xai_custom_endpoint_does_not_count_ambient_official_key() {
1691 let _lock = crate::test_support::lock_test_env();
1692 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1693 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1694 let _ambient = crate::test_support::EnvVarGuard::set("XAI_API_KEY", "ambient-xai-key");
1695 let config = crate::config::Config {
1696 provider: Some("xai".to_string()),
1697 providers: Some(crate::config::ProvidersConfig {
1698 xai: crate::config::ProviderConfig {
1699 base_url: Some("https://unrelated-gateway.example.test/v1".to_string()),
1700 model: Some("private-grok-model".to_string()),
1701 ..Default::default()
1702 },
1703 ..Default::default()
1704 }),
1705 ..Default::default()
1706 };
1707
1708 assert!(!crate::config::has_api_key_for(&config, ApiProvider::Xai));
1709 assert_eq!(
1710 credential_state_for_provider(&config, ApiProvider::Xai),
1711 CredentialState::MissingKey
1712 );
1713 }
1714
1715 #[test]
1716 fn active_deepseek_routes_validate_models_against_effective_custom_base_url() {
1717 let official = crate::config::Config::default();
1718 assert!(!route_is_valid_for_model(
1719 &official,
1720 ApiProvider::Deepseek,
1721 Some("anthropic/private-model")
1722 ));
1723
1724 for provider_name in ["deepseek", "deepseek-cn"] {
1725 let config = crate::config::Config {
1726 provider: Some(provider_name.to_string()),
1727 base_url: Some("https://tenant-gateway.example.test/v1".to_string()),
1728 default_text_model: Some("anthropic/private-model".to_string()),
1729 ..Default::default()
1730 };
1731 let provider = config.api_provider();
1732 assert!(matches!(
1733 provider,
1734 ApiProvider::Deepseek | ApiProvider::DeepseekCN
1735 ));
1736 assert!(route_is_valid_for_model(
1737 &config,
1738 provider,
1739 Some("anthropic/private-model")
1740 ));
1741 }
1742 }
1743
1744 #[test]
1745 fn cli_forwarded_deepseek_custom_route_validates_prefixed_model() {
1746 let _lock = crate::test_support::lock_test_env();
1747 let temp = tempfile::tempdir().expect("isolated config home");
1748 let config_path = temp.path().join("config.toml");
1749 std::fs::write(
1750 &config_path,
1751 r#"api_key = "saved-file-key"
1752 base_url = "https://api.deepseek.com/v1"
1753 default_text_model = "deepseek-chat"
1754 "#,
1755 )
1756 .expect("write config");
1757 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1758 let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek");
1759 let _legacy_provider = crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROVIDER");
1760 let _base = crate::test_support::EnvVarGuard::set(
1761 "CODEWHALE_BASE_URL",
1762 "https://tenant-gateway.example.test/v1",
1763 );
1764 let _legacy_base = crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL");
1765 let _model =
1766 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "anthropic/private-model");
1767 let _source = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
1768 let _cli_key =
1769 crate::test_support::EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
1770
1771 let config = crate::config::Config::load(Some(config_path), None).expect("load config");
1772 assert_eq!(config.default_model(), "anthropic/private-model");
1773 assert_eq!(
1774 config.active_route_api_key().expect("explicit CLI key"),
1775 "explicit-cli-key"
1776 );
1777 assert!(route_is_valid_for_model(
1778 &config,
1779 ApiProvider::Deepseek,
1780 Some("anthropic/private-model")
1781 ));
1782 }
1783
1784 #[test]
1785 fn builtin_loopback_local_and_api_key_routes_have_distinct_cache_identity() {
1786 let _lock = crate::test_support::lock_test_env();
1787 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
1788 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1789 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1790 let local = crate::config::Config {
1791 provider: Some("openai".to_string()),
1792 providers: Some(crate::config::ProvidersConfig {
1793 openai: crate::config::ProviderConfig {
1794 base_url: Some("http://127.0.0.1:8080/v1".to_string()),
1795 model: Some("local-model".to_string()),
1796 ..Default::default()
1797 },
1798 ..Default::default()
1799 }),
1800 ..Default::default()
1801 };
1802 let mut protected = local.clone();
1803 let protected_route = &mut protected.providers.as_mut().expect("providers").openai;
1804 protected_route.auth_mode = Some("api_key".to_string());
1805 protected_route.api_key = Some("protected-local-key".to_string());
1806
1807 assert_eq!(
1808 credential_state_for_provider(&local, ApiProvider::Openai),
1809 CredentialState::Local
1810 );
1811 assert_eq!(
1812 auth_class_for_provider(&local, ApiProvider::Openai),
1813 ProviderAuthClass::Local
1814 );
1815 assert_eq!(
1816 credential_state_for_provider(&protected, ApiProvider::Openai),
1817 CredentialState::Saved
1818 );
1819 assert_eq!(
1820 auth_class_for_provider(&protected, ApiProvider::Openai),
1821 ProviderAuthClass::ApiKey
1822 );
1823 assert_ne!(
1824 route_identity_for_model(&local, ApiProvider::Openai, "local-model"),
1825 route_identity_for_model(&protected, ApiProvider::Openai, "local-model")
1826 );
1827
1828 let mut checks = ProviderReadinessSnapshot::default();
1829 checks.record_success(&local, ApiProvider::Openai, "local-model");
1830 assert_eq!(
1831 resolve_for_model(&protected, ApiProvider::Openai, "local-model", &checks),
1832 ResolvedProviderReadiness::SavedUnchecked
1833 );
1834
1835 let deepseek_cn_local = crate::config::Config {
1836 provider: Some("deepseek-cn".to_string()),
1837 base_url: Some("http://127.0.0.1:9090/v1".to_string()),
1838 default_text_model: Some("local-cn-model".to_string()),
1839 ..Default::default()
1840 };
1841 assert_eq!(
1842 credential_state_for_provider(&deepseek_cn_local, ApiProvider::DeepseekCN),
1843 CredentialState::Local
1844 );
1845 }
1846 }
1847
1847 lines RUST