返回 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::{ProviderIdentity, ProviderKind};
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 identity: ProviderIdentity,
47 endpoint: String,
48 model: String,
49 auth_class: ProviderAuthClass,
50 credential_generation: Option<crate::route_receipt::CredentialGeneration>,
51 }
52
53 pub(crate) fn route_identity_for_model(
54 config: &crate::config::Config,
55 identity: &ProviderIdentity,
56 model: &str,
57 ) -> ProviderRouteIdentity {
58 let endpoint = crate::route_receipt::endpoint_identity(&config.base_url_for_route(identity));
59 ProviderRouteIdentity {
60 identity: identity.clone(),
61 endpoint,
62 model: model.trim().to_string(),
63 auth_class: auth_class_for_provider(config, identity),
64 credential_generation: config.readonly_health_credential_generation(identity),
65 }
66 }
67
68 pub(crate) fn auth_class_for_provider(
69 config: &crate::config::Config,
70 identity: &ProviderIdentity,
71 ) -> ProviderAuthClass {
72 let provider = identity.provider;
73 if config.verify_provider_identity(identity).is_err() {
74 return ProviderAuthClass::Legacy;
75 }
76 let auth_mode = config.auth_mode_for_provider(identity);
77 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
78 return ProviderAuthClass::NoAuth;
79 }
80 let official_endpoint = !config.provider_uses_custom_endpoint(identity);
81 if provider == ProviderKind::OpenaiCodex && official_endpoint {
82 return ProviderAuthClass::OAuth;
83 }
84 if provider == ProviderKind::Moonshot
85 && official_endpoint
86 && auth_mode
87 .as_deref()
88 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token)
89 {
90 return ProviderAuthClass::ImportedToken;
91 }
92 if provider == ProviderKind::Xai
93 && official_endpoint
94 && auth_mode
95 .as_deref()
96 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth)
97 {
98 return ProviderAuthClass::OAuth;
99 }
100 match credential_state_for_provider(config, identity) {
101 CredentialState::NoAuth => ProviderAuthClass::NoAuth,
102 CredentialState::Local => ProviderAuthClass::Local,
103 CredentialState::Legacy => ProviderAuthClass::Legacy,
104 CredentialState::ExternalConsent => ProviderAuthClass::OAuth,
105 _ => ProviderAuthClass::ApiKey,
106 }
107 }
108
109 pub(crate) fn credential_state_for_provider(
110 config: &crate::config::Config,
111 identity: &ProviderIdentity,
112 ) -> CredentialState {
113 let provider = identity.provider;
114 if provider == ProviderKind::Antigravity {
115 return CredentialState::Legacy;
116 }
117 if config.verify_provider_identity(identity).is_err() {
118 return CredentialState::MissingKey;
119 }
120 let auth_mode = config.auth_mode_for_provider(identity);
121 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
122 return CredentialState::NoAuth;
123 }
124 let api_key_required = crate::config::auth_mode_requires_api_key(auth_mode.as_deref());
125 let official_endpoint = !config.provider_uses_custom_endpoint(identity);
126
127 // A built-in provider can intentionally target a local OpenAI-compatible
128 // runtime. That route is keyless unless the operator explicitly declares
129 // an API-key auth contract. Classify it before provider-specific hosted
130 // branches (including the DeepSeek-CN compatibility alias) so readiness
131 // and cache identity describe the effective endpoint, not just the
132 // provider enum.
133 if config
134 .active_provider_identity()
135 .is_ok_and(|active| active == *identity)
136 && !official_endpoint
137 && crate::config::base_url_uses_local_host(&config.active_route_base_url())
138 {
139 return if api_key_required {
140 if crate::config::has_api_key_for(config, identity) {
141 CredentialState::Saved
142 } else {
143 CredentialState::MissingKey
144 }
145 } else {
146 CredentialState::Local
147 };
148 }
149
150 if provider == ProviderKind::Antigravity {
151 return CredentialState::Legacy;
152 }
153 if provider == ProviderKind::Custom {
154 let Some(configured) = config.provider_config_for(identity) else {
155 return CredentialState::MissingKey;
156 };
157 let auth_optional = configured
158 .base_url
159 .as_deref()
160 .is_some_and(crate::config::base_url_uses_local_host)
161 && !api_key_required;
162 if auth_optional {
163 return CredentialState::Local;
164 }
165 // The literal `custom` route (older top-level shape, now
166 // `[providers.custom]`) also owns the generic `custom` secret slot.
167 let has_auth = (config
168 .active_provider_identity()
169 .is_ok_and(|active| active == *identity)
170 && crate::config::explicit_cli_api_key_override().is_some())
171 || (config.selects_literal_custom_provider()
172 && crate::config::has_api_key_for(config, identity))
173 || configured.api_key.as_deref().is_some_and(|value| {
174 crate::config::classify_config_api_key_value(value)
175 == crate::config::ConfigApiKeyValueKind::Literal
176 })
177 || configured
178 .api_key_env
179 .as_deref()
180 .map(str::trim)
181 .filter(|name| !name.is_empty())
182 .is_some_and(|name| {
183 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
184 });
185 return if has_auth {
186 CredentialState::Saved
187 } else {
188 CredentialState::MissingKey
189 };
190 }
191 if crate::config::provider_route_is_keyless_self_hosted(
192 provider,
193 &config.base_url_for_route(identity),
194 ) {
195 return if api_key_required {
196 if crate::config::has_api_key_for(config, identity) {
197 CredentialState::Saved
198 } else {
199 CredentialState::MissingKey
200 }
201 } else {
202 CredentialState::Local
203 };
204 }
205
206 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
207 && official_endpoint
208 && auth_mode
209 .as_deref()
210 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token);
211 if uses_kimi_imported_token {
212 // Kimi remains API-key-only until Codewhale has its own registered
213 // OAuth client identity. Never inspect Kimi CLI storage here.
214 return CredentialState::MissingKey;
215 }
216 if provider == ProviderKind::OpenaiCodex && official_endpoint {
217 return if crate::config::has_api_key_for(config, identity) {
218 CredentialState::Saved
219 } else if config
220 .active_provider_identity()
221 .is_ok_and(|active| active != *identity)
222 && config.external_credential_read_consent_configured(
223 identity,
224 codewhale_config::ExternalCredentialSource::CodexCli,
225 )
226 {
227 CredentialState::ExternalConsent
228 } else {
229 CredentialState::MissingLogin
230 };
231 }
232 let xai_oauth_selected = provider == ProviderKind::Xai
233 && official_endpoint
234 && auth_mode
235 .as_deref()
236 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth);
237 if xai_oauth_selected {
238 // #5772: `Saved` means a credential was actually found. A surviving
239 // consent record whose file is gone or expired resolves below as
240 // `ExternalConsent` (dormant) or `MissingLogin`, never as `Saved`.
241 return if crate::oauth::credentials_valid(crate::oauth::OAuthProvider::Xai, config)
242 || explicit_provider_credential_present(config, identity)
243 {
244 CredentialState::Saved
245 } else if config
246 .active_provider_identity()
247 .is_ok_and(|active| active != *identity)
248 && config.external_credential_read_consent_configured(
249 identity,
250 codewhale_config::ExternalCredentialSource::GrokCli,
251 )
252 {
253 CredentialState::ExternalConsent
254 } else {
255 CredentialState::MissingLogin
256 };
257 }
258 if provider == ProviderKind::Xai && explicit_provider_credential_present(config, identity) {
259 return CredentialState::Saved;
260 }
261 if provider == ProviderKind::Xai {
262 return CredentialState::MissingKey;
263 }
264
265 if crate::config::has_api_key_for(config, identity) {
266 CredentialState::Saved
267 } else if matches!(
268 provider,
269 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
270 ) && official_endpoint
271 && config
272 .active_provider_identity()
273 .is_ok_and(|active| active != *identity)
274 && config.external_credential_read_consent_configured(
275 identity,
276 codewhale_config::ExternalCredentialSource::DshCli,
277 )
278 {
279 CredentialState::ExternalConsent
280 } else {
281 CredentialState::MissingKey
282 }
283 }
284
285 fn explicit_provider_credential_present(
286 config: &crate::config::Config,
287 identity: &ProviderIdentity,
288 ) -> bool {
289 let provider = identity.provider;
290 if config.verify_provider_identity(identity).is_err() {
291 return false;
292 }
293 (config
294 .active_provider_identity()
295 .is_ok_and(|active| active == *identity)
296 && crate::config::explicit_cli_api_key_override().is_some())
297 || (!config.provider_uses_custom_endpoint(identity)
298 && provider
299 .provider()
300 .env_vars()
301 .iter()
302 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty())))
303 || (config.config_credentials_are_bound_to_provider_endpoint(identity)
304 && config.provider_config_for(identity).is_some_and(|entry| {
305 entry.api_key.as_deref().is_some_and(|value| {
306 crate::config::classify_config_api_key_value(value)
307 == crate::config::ConfigApiKeyValueKind::Literal
308 }) || entry
309 .api_key_env
310 .as_deref()
311 .map(str::trim)
312 .filter(|name| !name.is_empty())
313 .is_some_and(|name| {
314 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
315 })
316 }))
317 }
318
319 /// Validate the configured provider/model/endpoint route without making a
320 /// network request. This is shared by model inventory, `/model`, and Fleet so
321 /// none of them can mark a route selectable when `/provider` would reject it.
322 pub(crate) fn route_is_valid_for_model(
323 config: &crate::config::Config,
324 identity: &ProviderIdentity,
325 model: Option<&str>,
326 ) -> bool {
327 let provider = identity.provider;
328 if config.verify_provider_identity(identity).is_err() || provider == ProviderKind::Antigravity {
329 return false;
330 }
331 let kind = provider;
332 let configured = config.provider_config_for(identity);
333 let configured_model = model
334 .map(str::trim)
335 .filter(|value| !value.is_empty())
336 .map(str::to_string)
337 .or_else(|| {
338 configured
339 .and_then(|entry| entry.model.as_deref())
340 .map(str::trim)
341 .filter(|value| !value.is_empty())
342 .map(str::to_string)
343 });
344 let active_model = (config
345 .active_provider_identity()
346 .is_ok_and(|active| active == *identity))
347 .then(|| config.default_model())
348 .filter(|model| !model.trim().is_empty() && !model.eq_ignore_ascii_case("auto"));
349 let request = RouteRequest {
350 explicit_provider: Some(kind),
351 model_selector: configured_model.or(active_model).map(LogicalModelRef::from),
352 saved_provider_model: None,
353 base_url_override: Some(config.base_url_for_route(identity)),
354 limit_overrides: Vec::new(),
355 };
356 RouteResolver::new()
357 .resolve(&request)
358 .is_ok_and(|candidate| candidate.validation().ok)
359 }
360
361 #[derive(Debug, Clone, PartialEq, Eq)]
362 pub(crate) enum LastProviderCheck {
363 Passed,
364 /// A 2xx `/models` response proves reachability only — never model readiness.
365 ModelsEndpointPassed,
366 Failed {
367 category: ErrorCategory,
368 message: String,
369 },
370 }
371
372 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
373 #[serde(rename_all = "snake_case")]
374 pub(crate) enum ResolvedProviderReadiness {
375 MissingKey,
376 MissingLogin,
377 ExternalConsentPendingSelection,
378 SavedUnchecked,
379 ImportedTokenUnchecked,
380 NoAuthUnchecked,
381 LocalUnchecked,
382 Ready,
383 ConnectionCheckedModelUnchecked,
384 SavedLastCheckFailed {
385 category: ErrorCategory,
386 message: String,
387 },
388 InvalidRoute,
389 Legacy,
390 }
391
392 impl ResolvedProviderReadiness {
393 pub(crate) fn label(&self) -> Cow<'static, str> {
394 match self {
395 Self::MissingKey => Cow::Borrowed("missing key"),
396 Self::MissingLogin => Cow::Borrowed("missing login"),
397 Self::ExternalConsentPendingSelection => {
398 Cow::Borrowed("external consent · select to check")
399 }
400 Self::SavedUnchecked => Cow::Borrowed("key saved · not checked"),
401 Self::ImportedTokenUnchecked => Cow::Borrowed("imported token · not checked"),
402 Self::NoAuthUnchecked => Cow::Borrowed("no auth · not checked"),
403 Self::LocalUnchecked => Cow::Borrowed("local · not checked"),
404 Self::Ready => Cow::Borrowed("ready"),
405 Self::ConnectionCheckedModelUnchecked => {
406 Cow::Borrowed("models endpoint 2xx · model not checked")
407 }
408 Self::SavedLastCheckFailed { category, .. } => {
409 Cow::Owned(format!("last check failed ({category})"))
410 }
411 Self::InvalidRoute => Cow::Borrowed("invalid route"),
412 Self::Legacy => Cow::Borrowed("legacy"),
413 }
414 }
415
416 pub(crate) fn detail(&self) -> Option<&str> {
417 match self {
418 Self::SavedLastCheckFailed { message, .. } => Some(message),
419 _ => None,
420 }
421 }
422
423 pub(crate) fn can_attempt(&self) -> bool {
424 matches!(
425 self,
426 Self::SavedUnchecked
427 | Self::NoAuthUnchecked
428 | Self::LocalUnchecked
429 | Self::ImportedTokenUnchecked
430 | Self::Ready
431 | Self::SavedLastCheckFailed { .. }
432 )
433 }
434
435 /// Whether the provider needs an explicit human activation step before it
436 /// can be used. Fleet uses this to turn a dormant external-consent route
437 /// into a real selection without weakening the global readiness boundary.
438 pub(crate) fn requires_explicit_activation(&self) -> bool {
439 matches!(self, Self::ExternalConsentPendingSelection)
440 }
441
442 /// A short, non-sensitive reason this row cannot be activated. `None`
443 /// means the row is either ready or requires explicit activation.
444 pub(crate) fn blocked_reason(&self) -> Option<Cow<'static, str>> {
445 match self {
446 Self::MissingKey => Some(Cow::Borrowed("missing API key")),
447 Self::MissingLogin => Some(Cow::Borrowed("missing login")),
448 Self::InvalidRoute => Some(Cow::Borrowed("invalid route")),
449 Self::Legacy => Some(Cow::Borrowed("legacy route")),
450 Self::ConnectionCheckedModelUnchecked => Some(Cow::Borrowed("model not checked")),
451 Self::SavedLastCheckFailed { message, .. } => Some(Cow::Owned(message.clone())),
452 Self::SavedUnchecked
453 | Self::ImportedTokenUnchecked
454 | Self::NoAuthUnchecked
455 | Self::LocalUnchecked
456 | Self::Ready
457 | Self::ExternalConsentPendingSelection => None,
458 }
459 }
460 }
461
462 #[derive(Debug, Clone, Default)]
463 pub(crate) struct ProviderReadinessSnapshot {
464 checks: Vec<(ProviderRouteIdentity, LastProviderCheck)>,
465 /// Credentials the provider refused (authentication failures), keyed by
466 /// route without the model: a rejected key is rejected for every model.
467 /// Unlike `checks`, an entry may carry no credential generation — an
468 /// ambient `*_API_KEY`, a secret-store key or a command source cannot be
469 /// fingerprinted read-only, and without this the picker kept offering a
470 /// refused env key as "key saved · not checked".
471 ///
472 /// Known limitation: an opaque (generation-less) rejection cannot see a
473 /// key rotated outside this process; it lasts until the next success on
474 /// that route, or until a saved key makes the generation provable.
475 credential_rejections: Vec<(ProviderRouteIdentity, LastProviderCheck)>,
476 }
477
478 fn same_credential(candidate: &ProviderRouteIdentity, identity: &ProviderRouteIdentity) -> bool {
479 candidate.identity == identity.identity
480 && candidate.endpoint == identity.endpoint
481 && candidate.auth_class == identity.auth_class
482 && candidate.credential_generation == identity.credential_generation
483 }
484
485 impl ProviderReadinessSnapshot {
486 fn last(&self, identity: &ProviderRouteIdentity) -> Option<&LastProviderCheck> {
487 // A refused credential outranks any older per-model success with it;
488 // a later success on the route clears the rejection.
489 if let Some(check) = self
490 .credential_rejections
491 .iter()
492 .rev()
493 .find_map(|(candidate, check)| same_credential(candidate, identity).then_some(check))
494 {
495 return Some(check);
496 }
497 identity.credential_generation.as_ref()?;
498 if let Some(check) = self
499 .checks
500 .iter()
501 .rev()
502 .find_map(|(candidate, check)| (candidate == identity).then_some(check))
503 {
504 return Some(check);
505 }
506 // The auto model route never records under the literal "auto" —
507 // successes and failures are recorded against the concrete model
508 // the router actually ran. Without this fallback every auto-mode
509 // read would report "not checked" forever, even after hundreds of
510 // successful turns on that route.
511 if identity.model != "auto" {
512 return None;
513 }
514 self.checks
515 .iter()
516 .rev()
517 .find_map(|(candidate, check)| same_credential(candidate, identity).then_some(check))
518 }
519
520 pub(crate) fn record_success(
521 &mut self,
522 config: &crate::config::Config,
523 receipt: &crate::route_receipt::TurnRouteReceipt,
524 model: &str,
525 ) {
526 let identity = receipt.admitted_identity();
527 let mut route = route_identity_for_model(config, identity, model);
528 route.endpoint = receipt.endpoint_identity().to_string();
529 route.credential_generation = Some(receipt.credential_generation().clone());
530 self.credential_rejections.retain(|(candidate, _)| {
531 candidate.identity != route.identity || candidate.endpoint != route.endpoint
532 });
533 self.replace(route, LastProviderCheck::Passed);
534 }
535
536 /// Records a failed `/models` probe. Unlike [`Self::record_failure`],
537 /// this always stores the result so Test Connection can refresh a
538 /// stuck `not checked` row.
539 pub(crate) fn record_models_probe_failure(
540 &mut self,
541 config: &crate::config::Config,
542 identity: &ProviderIdentity,
543 model: &str,
544 generation: crate::route_receipt::CredentialGeneration,
545 category: ErrorCategory,
546 message: &str,
547 ) {
548 let mut route = route_identity_for_model(config, identity, model);
549 route.credential_generation = Some(generation);
550 self.replace(
551 route,
552 LastProviderCheck::Failed {
553 category,
554 message: sanitize_message(message),
555 },
556 );
557 }
558
559 /// Records a 2xx `/models` probe as connection-checked, never `Ready`.
560 pub(crate) fn record_models_probe_success(
561 &mut self,
562 config: &crate::config::Config,
563 identity: &ProviderIdentity,
564 model: &str,
565 generation: crate::route_receipt::CredentialGeneration,
566 ) {
567 let mut route = route_identity_for_model(config, identity, model);
568 route.credential_generation = Some(generation);
569 self.replace(route, LastProviderCheck::ModelsEndpointPassed);
570 }
571
572 pub(crate) fn record_failure(
573 &mut self,
574 config: &crate::config::Config,
575 receipt: &crate::route_receipt::TurnRouteReceipt,
576 model: &str,
577 envelope: &ErrorEnvelope,
578 ) {
579 let identity = receipt.admitted_identity();
580 let mut route = route_identity_for_model(config, identity, model);
581 route.endpoint = receipt.endpoint_identity().to_string();
582 route.credential_generation = Some(receipt.credential_generation().clone());
583 if !provider_owned_failure(envelope) {
584 return;
585 }
586 let check = LastProviderCheck::Failed {
587 category: envelope.category,
588 message: sanitize_message(&envelope.message),
589 };
590 if envelope.category == ErrorCategory::Authentication {
591 // Record the rejection under the generation the picker will
592 // compute. When that projection proves a different credential is
593 // configured now, the refused one is already gone.
594 let current = config.readonly_health_credential_generation(identity);
595 if current
596 .as_ref()
597 .is_none_or(|generation| generation == receipt.credential_generation())
598 {
599 let mut rejected = route.clone();
600 rejected.model.clear();
601 rejected.credential_generation = current;
602 self.credential_rejections
603 .retain(|(candidate, _)| !same_credential(candidate, &rejected));
604 self.credential_rejections.push((rejected, check.clone()));
605 }
606 }
607 self.replace(route, check);
608 }
609
610 #[cfg(test)]
611 pub(crate) fn record_failure_message(
612 &mut self,
613 config: &crate::config::Config,
614 identity: &ProviderIdentity,
615 model: &str,
616 category: ErrorCategory,
617 message: &str,
618 ) {
619 self.replace(
620 route_identity_for_model(config, identity, model),
621 LastProviderCheck::Failed {
622 category,
623 message: sanitize_message(message),
624 },
625 );
626 }
627
628 fn replace(&mut self, identity: ProviderRouteIdentity, check: LastProviderCheck) {
629 self.checks.retain(|(candidate, _)| candidate != &identity);
630 self.checks.push((identity, check));
631 }
632 }
633
634 pub(crate) fn resolve_with_identity(
635 identity: &ProviderRouteIdentity,
636 credentials: CredentialState,
637 route_ok: bool,
638 checks: &ProviderReadinessSnapshot,
639 ) -> ResolvedProviderReadiness {
640 if !route_ok {
641 return ResolvedProviderReadiness::InvalidRoute;
642 }
643 match credentials {
644 CredentialState::Legacy => ResolvedProviderReadiness::Legacy,
645 CredentialState::MissingKey => ResolvedProviderReadiness::MissingKey,
646 CredentialState::MissingLogin => ResolvedProviderReadiness::MissingLogin,
647 CredentialState::ExternalConsent => match checks.last(identity) {
648 Some(LastProviderCheck::Passed) => ResolvedProviderReadiness::Ready,
649 Some(LastProviderCheck::ModelsEndpointPassed) => {
650 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
651 }
652 Some(LastProviderCheck::Failed { category, message }) => {
653 ResolvedProviderReadiness::SavedLastCheckFailed {
654 category: *category,
655 message: message.clone(),
656 }
657 }
658 None => ResolvedProviderReadiness::ExternalConsentPendingSelection,
659 },
660 CredentialState::Saved
661 | CredentialState::ImportedToken
662 | CredentialState::NoAuth
663 | CredentialState::Local => match checks.last(identity) {
664 Some(LastProviderCheck::Passed) => ResolvedProviderReadiness::Ready,
665 Some(LastProviderCheck::ModelsEndpointPassed) => {
666 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
667 }
668 Some(LastProviderCheck::Failed { category, message }) => {
669 ResolvedProviderReadiness::SavedLastCheckFailed {
670 category: *category,
671 message: message.clone(),
672 }
673 }
674 None if credentials == CredentialState::NoAuth => {
675 ResolvedProviderReadiness::NoAuthUnchecked
676 }
677 None if credentials == CredentialState::Local => {
678 ResolvedProviderReadiness::LocalUnchecked
679 }
680 None if credentials == CredentialState::ImportedToken => {
681 ResolvedProviderReadiness::ImportedTokenUnchecked
682 }
683 None => ResolvedProviderReadiness::SavedUnchecked,
684 },
685 }
686 }
687
688 pub(crate) fn resolve_for_model(
689 config: &crate::config::Config,
690 identity: &ProviderIdentity,
691 model: &str,
692 checks: &ProviderReadinessSnapshot,
693 ) -> ResolvedProviderReadiness {
694 resolve_with_identity(
695 &route_identity_for_model(config, identity, model),
696 credential_state_for_provider(config, identity),
697 route_is_valid_for_model(config, identity, Some(model)),
698 checks,
699 )
700 }
701
702 fn provider_owned_failure(envelope: &ErrorEnvelope) -> bool {
703 matches!(
704 envelope.category,
705 ErrorCategory::Network
706 | ErrorCategory::Authentication
707 | ErrorCategory::Authorization
708 | ErrorCategory::RateLimit
709 | ErrorCategory::Timeout
710 )
711 }
712
713 fn sanitize_message(message: &str) -> String {
714 crate::utils::truncate_with_ellipsis(message.trim(), 120, "…")
715 }
716
717 #[cfg(test)]
718 mod tests {
719 use super::*;
720 use crate::error_taxonomy::ErrorSeverity;
721
722 fn literal_health_config(kind: ProviderKind) -> crate::config::Config {
723 let mut config = crate::config::Config {
724 provider: Some(kind.as_str().into()),
725 ..Default::default()
726 };
727 let identity = config.builtin_provider_identity(kind).unwrap();
728 config.provider_config_for_mut(&identity).unwrap().api_key =
729 Some("health-fixture-key".into());
730 config
731 }
732
733 fn resolve_test_route(
734 config: &crate::config::Config,
735 provider: ProviderKind,
736 model: &str,
737 credentials: CredentialState,
738 route_ok: bool,
739 checks: &ProviderReadinessSnapshot,
740 ) -> ResolvedProviderReadiness {
741 resolve_with_identity(
742 &route_identity_for_model(config, &(config).test_identity_for_kind(provider), model),
743 credentials,
744 route_ok,
745 checks,
746 )
747 }
748
749 #[test]
750 fn saved_credentials_are_never_ready_without_observed_success() {
751 let config = crate::config::Config::default();
752 let checks = ProviderReadinessSnapshot::default();
753 assert_eq!(
754 resolve_test_route(
755 &config,
756 ProviderKind::Deepseek,
757 "deepseek-v4-pro",
758 CredentialState::Saved,
759 true,
760 &checks,
761 ),
762 ResolvedProviderReadiness::SavedUnchecked
763 );
764 }
765
766 #[test]
767 fn deepseek_cn_compatibility_alias_uses_real_key_readiness() {
768 let _lock = crate::test_support::lock_test_env();
769 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
770 let missing = crate::config::Config {
771 provider: Some("deepseek-cn".to_string()),
772 ..Default::default()
773 };
774 assert_eq!(
775 credential_state_for_provider(
776 &missing,
777 &(missing)
778 .resolve_provider_selection_identity("deepseek-cn")
779 .unwrap()
780 ),
781 CredentialState::MissingKey
782 );
783
784 let configured = crate::config::Config {
785 provider: Some("deepseek-cn".to_string()),
786 providers: Some(crate::config::ProvidersConfig {
787 deepseek_cn: crate::config::ProviderConfig {
788 api_key: Some("deepseek-cn-test-key".to_string()),
789 ..Default::default()
790 },
791 ..Default::default()
792 }),
793 ..Default::default()
794 };
795 assert_eq!(
796 credential_state_for_provider(
797 &configured,
798 &(configured)
799 .resolve_provider_selection_identity("deepseek-cn")
800 .unwrap()
801 ),
802 CredentialState::Saved
803 );
804 }
805
806 #[test]
807 fn custom_readiness_identity_preserves_case_sensitive_route_parts() {
808 let custom = std::collections::HashMap::from([
809 (
810 "CUSTOM".to_string(),
811 crate::config::ProviderConfig {
812 kind: Some("openai-compatible".to_string()),
813 base_url: Some("https://example.test/TenantA/v1".to_string()),
814 model: Some("Vendor/ModelA".to_string()),
815 api_key: Some("test-key-a".to_string()),
816 ..Default::default()
817 },
818 ),
819 (
820 "custom".to_string(),
821 crate::config::ProviderConfig {
822 kind: Some("openai-compatible".to_string()),
823 base_url: Some("https://example.test/tenanta/v1".to_string()),
824 model: Some("vendor/modela".to_string()),
825 api_key: Some("test-key-b".to_string()),
826 ..Default::default()
827 },
828 ),
829 ]);
830 let upper = crate::config::Config {
831 provider: Some("CUSTOM".to_string()),
832 providers: Some(crate::config::ProvidersConfig {
833 custom: custom.clone(),
834 ..Default::default()
835 }),
836 ..Default::default()
837 };
838 let lower = crate::config::Config {
839 provider: Some("custom".to_string()),
840 providers: Some(crate::config::ProvidersConfig {
841 custom,
842 ..Default::default()
843 }),
844 ..Default::default()
845 };
846 let upper_identity = route_identity_for_model(
847 &upper,
848 &(upper).test_identity_for_kind(ProviderKind::Custom),
849 "Vendor/ModelA",
850 );
851 let lower_identity = route_identity_for_model(
852 &lower,
853 &(lower).test_identity_for_kind(ProviderKind::Custom),
854 "vendor/modela",
855 );
856
857 assert_ne!(upper_identity, lower_identity);
858 assert_eq!(upper_identity.identity.key.as_str(), "CUSTOM");
859 assert_eq!(upper_identity.endpoint, "https://example.test/TenantA/v1");
860 assert_eq!(upper_identity.model, "Vendor/ModelA");
861
862 let mut checks = ProviderReadinessSnapshot::default();
863 checks.record_success(
864 &upper,
865 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
866 &upper,
867 ProviderKind::Custom,
868 "Vendor/ModelA",
869 ),
870 "Vendor/ModelA",
871 );
872 assert_eq!(
873 resolve_for_model(
874 &lower,
875 &(lower).test_identity_for_kind(ProviderKind::Custom),
876 "vendor/modela",
877 &checks
878 ),
879 ResolvedProviderReadiness::SavedUnchecked
880 );
881 }
882
883 #[test]
884 fn external_consent_cached_turn_cannot_prove_current_opaque_generation() {
885 let config = crate::config::Config::default();
886 let mut checks = ProviderReadinessSnapshot::default();
887 checks.record_success(
888 &config,
889 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
890 &config,
891 ProviderKind::OpenaiCodex,
892 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
893 ),
894 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
895 );
896 assert_eq!(
897 resolve_test_route(
898 &config,
899 ProviderKind::OpenaiCodex,
900 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
901 CredentialState::ExternalConsent,
902 true,
903 &checks,
904 ),
905 ResolvedProviderReadiness::ExternalConsentPendingSelection
906 );
907 }
908
909 #[test]
910 fn external_consent_cached_failure_cannot_prove_current_opaque_generation() {
911 let config = crate::config::Config::default();
912 let mut checks = ProviderReadinessSnapshot::default();
913 checks.record_failure_message(
914 &config,
915 &(config).test_identity_for_kind(ProviderKind::Xai),
916 "grok-4.5",
917 ErrorCategory::Authentication,
918 "consent revoked",
919 );
920 assert_eq!(
921 resolve_test_route(
922 &config,
923 ProviderKind::Xai,
924 "grok-4.5",
925 CredentialState::ExternalConsent,
926 true,
927 &checks
928 ),
929 ResolvedProviderReadiness::ExternalConsentPendingSelection
930 );
931 }
932
933 #[test]
934 fn external_consent_without_check_stays_pending_selection() {
935 let config = crate::config::Config::default();
936 let checks = ProviderReadinessSnapshot::default();
937 assert_eq!(
938 resolve_test_route(
939 &config,
940 ProviderKind::OpenaiCodex,
941 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
942 CredentialState::ExternalConsent,
943 true,
944 &checks,
945 ),
946 ResolvedProviderReadiness::ExternalConsentPendingSelection
947 );
948 }
949
950 #[test]
951 fn models_probe_success_marks_connection_checked_not_ready() {
952 let _lock = crate::test_support::lock_test_env();
953 let _cli = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
954 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
955 let config = literal_health_config(ProviderKind::Deepseek);
956 let mut checks = ProviderReadinessSnapshot::default();
957 checks.record_models_probe_success(
958 &config,
959 &(config).test_identity_for_kind(ProviderKind::Deepseek),
960 "deepseek-v4-pro",
961 crate::route_receipt::TurnRouteReceipt::for_test_fixture(
962 &config,
963 ProviderKind::Deepseek,
964 "deepseek-v4-pro",
965 )
966 .credential_generation()
967 .clone(),
968 );
969 assert_eq!(
970 resolve_test_route(
971 &config,
972 ProviderKind::Deepseek,
973 "deepseek-v4-pro",
974 CredentialState::Saved,
975 true,
976 &checks,
977 ),
978 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
979 );
980 assert_eq!(
981 ResolvedProviderReadiness::ConnectionCheckedModelUnchecked.label(),
982 "models endpoint 2xx · model not checked"
983 );
984 assert!(!ResolvedProviderReadiness::ConnectionCheckedModelUnchecked.can_attempt());
985 }
986
987 #[test]
988 fn success_and_provider_failure_replace_session_evidence() {
989 let _lock = crate::test_support::lock_test_env();
990 let _cli = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
991 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
992 let config = literal_health_config(ProviderKind::Zai);
993 let mut checks = ProviderReadinessSnapshot::default();
994 checks.record_success(
995 &config,
996 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
997 &config,
998 ProviderKind::Zai,
999 "glm-5.2",
1000 ),
1001 "glm-5.2",
1002 );
1003 assert_eq!(
1004 resolve_test_route(
1005 &config,
1006 ProviderKind::Zai,
1007 "glm-5.2",
1008 CredentialState::Saved,
1009 true,
1010 &checks,
1011 ),
1012 ResolvedProviderReadiness::Ready
1013 );
1014
1015 checks.record_failure(
1016 &config,
1017 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1018 &config,
1019 ProviderKind::Zai,
1020 "glm-5.2",
1021 ),
1022 "glm-5.2",
1023 &ErrorEnvelope::new(
1024 ErrorCategory::Authentication,
1025 ErrorSeverity::Error,
1026 false,
1027 "auth_failed",
1028 "token rejected",
1029 ),
1030 );
1031 let resolved = resolve_test_route(
1032 &config,
1033 ProviderKind::Zai,
1034 "glm-5.2",
1035 CredentialState::Saved,
1036 true,
1037 &checks,
1038 );
1039 assert!(matches!(
1040 resolved,
1041 ResolvedProviderReadiness::SavedLastCheckFailed {
1042 category: ErrorCategory::Authentication,
1043 ..
1044 }
1045 ));
1046 assert!(resolved.can_attempt());
1047 }
1048
1049 #[test]
1050 fn tool_failures_do_not_poison_provider_health() {
1051 let _lock = crate::test_support::lock_test_env();
1052 let _cli = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1053 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1054 let config = literal_health_config(ProviderKind::Deepseek);
1055 let mut checks = ProviderReadinessSnapshot::default();
1056 checks.record_success(
1057 &config,
1058 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1059 &config,
1060 ProviderKind::Deepseek,
1061 "deepseek-v4-pro",
1062 ),
1063 "deepseek-v4-pro",
1064 );
1065 checks.record_failure(
1066 &config,
1067 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1068 &config,
1069 ProviderKind::Deepseek,
1070 "deepseek-v4-pro",
1071 ),
1072 "deepseek-v4-pro",
1073 &ErrorEnvelope::new(
1074 ErrorCategory::Tool,
1075 ErrorSeverity::Error,
1076 false,
1077 "tool_failed",
1078 "shell failed",
1079 ),
1080 );
1081 assert!(matches!(
1082 resolve_test_route(
1083 &config,
1084 ProviderKind::Deepseek,
1085 "deepseek-v4-pro",
1086 CredentialState::Saved,
1087 true,
1088 &checks,
1089 ),
1090 ResolvedProviderReadiness::Ready
1091 ));
1092 }
1093
1094 #[test]
1095 fn route_and_missing_auth_states_dominate_health() {
1096 let config = crate::config::Config {
1097 provider: Some("openai-codex".to_string()),
1098 ..Default::default()
1099 };
1100 let mut checks = ProviderReadinessSnapshot::default();
1101 checks.record_success(
1102 &config,
1103 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1104 &config,
1105 ProviderKind::OpenaiCodex,
1106 "gpt-5.5",
1107 ),
1108 "gpt-5.5",
1109 );
1110 assert_eq!(
1111 resolve_test_route(
1112 &config,
1113 ProviderKind::OpenaiCodex,
1114 "gpt-5.5",
1115 CredentialState::MissingLogin,
1116 true,
1117 &checks
1118 ),
1119 ResolvedProviderReadiness::MissingLogin
1120 );
1121 assert_eq!(
1122 resolve_test_route(
1123 &config,
1124 ProviderKind::OpenaiCodex,
1125 "gpt-5.5",
1126 CredentialState::Saved,
1127 false,
1128 &checks
1129 ),
1130 ResolvedProviderReadiness::InvalidRoute
1131 );
1132 }
1133
1134 #[test]
1135 fn api_key_success_does_not_verify_new_xai_oauth_route() {
1136 let _lock = crate::test_support::lock_test_env();
1137 let temp = tempfile::tempdir().expect("oauth fixture root");
1138 let oauth_path = temp.path().join("grok-auth.json");
1139 std::fs::write(
1140 &oauth_path,
1141 serde_json::to_vec(&serde_json::json!({
1142 "test-scope": {
1143 "key": "expired-access-token",
1144 "refresh_token": "saved-refresh-token",
1145 "expires_at": "2000-01-01T00:00:00Z",
1146 "auth_mode": "oidc"
1147 }
1148 }))
1149 .expect("oauth json"),
1150 )
1151 .expect("oauth fixture");
1152 let _oauth_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &oauth_path);
1153
1154 let api_key_config = crate::config::Config {
1155 provider: Some("xai".to_string()),
1156 providers: Some(crate::config::ProvidersConfig {
1157 xai: crate::config::ProviderConfig {
1158 api_key: Some("xai-test-key".to_string()),
1159 auth_mode: Some("api_key".to_string()),
1160 ..Default::default()
1161 },
1162 ..Default::default()
1163 }),
1164 ..Default::default()
1165 };
1166 let mut checks = ProviderReadinessSnapshot::default();
1167 let api_key_model = api_key_config.default_model();
1168 checks.record_success(
1169 &api_key_config,
1170 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1171 &api_key_config,
1172 ProviderKind::Xai,
1173 &api_key_model,
1174 ),
1175 &api_key_model,
1176 );
1177
1178 let mut oauth_config = api_key_config;
1179 oauth_config
1180 .providers
1181 .as_mut()
1182 .expect("providers")
1183 .xai
1184 .auth_mode = Some("oauth".to_string());
1185 assert_eq!(
1186 credential_state_for_provider(
1187 &oauth_config,
1188 &(oauth_config).test_identity_for_kind(ProviderKind::Xai)
1189 ),
1190 CredentialState::Saved,
1191 "fixture must have structurally valid OAuth material"
1192 );
1193 let model = oauth_config.default_model();
1194 assert_eq!(
1195 resolve_for_model(
1196 &oauth_config,
1197 &(oauth_config).test_identity_for_kind(ProviderKind::Xai),
1198 &model,
1199 &checks
1200 ),
1201 ResolvedProviderReadiness::SavedUnchecked,
1202 "API-key evidence must not cross the auth-class boundary"
1203 );
1204 }
1205
1206 #[test]
1207 fn observed_success_is_scoped_to_exact_model_endpoint_and_custom_provider() {
1208 let deepseek = crate::config::Config {
1209 ..Default::default()
1210 }
1211 .with_legacy_root(Some("deepseek-test-key".to_string()), None);
1212 let mut checks = ProviderReadinessSnapshot::default();
1213 checks.record_success(
1214 &deepseek,
1215 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1216 &deepseek,
1217 ProviderKind::Deepseek,
1218 "deepseek-v4-pro",
1219 ),
1220 "deepseek-v4-pro",
1221 );
1222 assert_eq!(
1223 resolve_for_model(
1224 &deepseek,
1225 &(deepseek).test_identity_for_kind(ProviderKind::Deepseek),
1226 "deepseek-v4-flash",
1227 &checks,
1228 ),
1229 ResolvedProviderReadiness::SavedUnchecked,
1230 "one model entitlement must not verify a sibling model"
1231 );
1232
1233 let custom_config = |id: &str, endpoint: &str| crate::config::Config {
1234 provider: Some(id.to_string()),
1235 providers: Some(crate::config::ProvidersConfig {
1236 custom: std::collections::HashMap::from([(
1237 id.to_string(),
1238 crate::config::ProviderConfig {
1239 kind: Some("openai-compatible".to_string()),
1240 base_url: Some(endpoint.to_string()),
1241 model: Some("private-coder".to_string()),
1242 api_key: Some("custom-test-key".to_string()),
1243 ..Default::default()
1244 },
1245 )]),
1246 ..Default::default()
1247 }),
1248 ..Default::default()
1249 };
1250 let alpha = custom_config("alpha", "https://alpha.example/v1");
1251 checks.record_success(
1252 &alpha,
1253 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1254 &alpha,
1255 ProviderKind::Custom,
1256 "private-coder",
1257 ),
1258 "private-coder",
1259 );
1260
1261 let beta = custom_config("beta", "https://alpha.example/v1");
1262 assert_eq!(
1263 resolve_for_model(
1264 &beta,
1265 &(beta).test_identity_for_kind(ProviderKind::Custom),
1266 "private-coder",
1267 &checks
1268 ),
1269 ResolvedProviderReadiness::SavedUnchecked,
1270 "named custom providers must not share observed health"
1271 );
1272
1273 let alpha_moved = custom_config("alpha", "https://other.example/v1");
1274 assert_eq!(
1275 resolve_for_model(
1276 &alpha_moved,
1277 &(alpha_moved).test_identity_for_kind(ProviderKind::Custom),
1278 "private-coder",
1279 &checks,
1280 ),
1281 ResolvedProviderReadiness::SavedUnchecked,
1282 "changing endpoints must invalidate observed health"
1283 );
1284 }
1285
1286 #[test]
1287 fn auto_model_readiness_follows_the_route_not_the_literal_identity() {
1288 let config = crate::config::Config {
1289 ..Default::default()
1290 }
1291 .with_legacy_root(Some("test-key".to_string()), None);
1292 let mut checks = ProviderReadinessSnapshot::default();
1293
1294 // Before any observed turn, auto honestly reports unchecked.
1295 assert_eq!(
1296 resolve_for_model(
1297 &config,
1298 &(config).test_identity_for_kind(ProviderKind::Deepseek),
1299 "auto",
1300 &checks
1301 ),
1302 ResolvedProviderReadiness::SavedUnchecked,
1303 "auto with no observed history must stay unchecked"
1304 );
1305
1306 // A successful concrete-model turn on the route verifies auto too —
1307 // the router picks the model, so per-model scoping cannot apply.
1308 checks.record_success(
1309 &config,
1310 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1311 &config,
1312 ProviderKind::Deepseek,
1313 "deepseek-v4-flash",
1314 ),
1315 "deepseek-v4-flash",
1316 );
1317 assert_eq!(
1318 resolve_for_model(
1319 &config,
1320 &(config).test_identity_for_kind(ProviderKind::Deepseek),
1321 "auto",
1322 &checks
1323 ),
1324 ResolvedProviderReadiness::Ready,
1325 "a passed turn on the route must clear auto's not-checked badge"
1326 );
1327
1328 // A later failure on the same route must surface for auto as well.
1329 checks.record_failure_message(
1330 &config,
1331 &(config).test_identity_for_kind(ProviderKind::Deepseek),
1332 "deepseek-v4-pro",
1333 crate::error_taxonomy::ErrorCategory::Authentication,
1334 "401 unauthorized",
1335 );
1336 assert!(
1337 matches!(
1338 resolve_for_model(
1339 &config,
1340 &(config).test_identity_for_kind(ProviderKind::Deepseek),
1341 "auto",
1342 &checks
1343 ),
1344 ResolvedProviderReadiness::SavedLastCheckFailed { .. }
1345 ),
1346 "the most recent route check must win for auto, including failures"
1347 );
1348 }
1349
1350 #[test]
1351 fn auto_readiness_does_not_leak_across_providers_or_endpoints() {
1352 let custom_config = |id: &str, endpoint: &str| crate::config::Config {
1353 provider: Some(id.to_string()),
1354 providers: Some(crate::config::ProvidersConfig {
1355 custom: std::collections::HashMap::from([(
1356 id.to_string(),
1357 crate::config::ProviderConfig {
1358 kind: Some("openai-compatible".to_string()),
1359 base_url: Some(endpoint.to_string()),
1360 model: Some("private-coder".to_string()),
1361 api_key: Some("test-key".to_string()),
1362 ..Default::default()
1363 },
1364 )]),
1365 ..Default::default()
1366 }),
1367 ..Default::default()
1368 };
1369 let mut checks = ProviderReadinessSnapshot::default();
1370 let alpha = custom_config("alpha", "https://alpha.example/v1");
1371 checks.record_success(
1372 &alpha,
1373 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1374 &alpha,
1375 ProviderKind::Custom,
1376 "private-coder",
1377 ),
1378 "private-coder",
1379 );
1380
1381 // Same endpoint, different named provider: no leak.
1382 let beta = custom_config("beta", "https://alpha.example/v1");
1383 assert_eq!(
1384 resolve_for_model(
1385 &beta,
1386 &(beta).test_identity_for_kind(ProviderKind::Custom),
1387 "auto",
1388 &checks
1389 ),
1390 ResolvedProviderReadiness::SavedUnchecked,
1391 "auto fallback is scoped to the route, not the workspace"
1392 );
1393
1394 // Same named provider, different endpoint: no leak.
1395 let alpha_moved = custom_config("alpha", "https://other.example/v1");
1396 assert_eq!(
1397 resolve_for_model(
1398 &alpha_moved,
1399 &(alpha_moved).test_identity_for_kind(ProviderKind::Custom),
1400 "auto",
1401 &checks
1402 ),
1403 ResolvedProviderReadiness::SavedUnchecked,
1404 "changing endpoints must invalidate the auto fallback too"
1405 );
1406 }
1407
1408 #[test]
1409 fn disabled_external_imports_are_not_probed_by_readiness() {
1410 let _lock = crate::test_support::lock_test_env();
1411 let temp = tempfile::tempdir().expect("oauth fixture root");
1412 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1413 let kimi_home = temp.path().join("kimi");
1414 std::fs::create_dir_all(kimi_home.join("credentials")).expect("kimi credentials dir");
1415 std::fs::write(kimi_home.join("credentials/kimi-code.json"), "{not-json")
1416 .expect("malformed kimi fixture");
1417 let _kimi_home = crate::test_support::EnvVarGuard::set(
1418 "KIMI_CODE_HOME",
1419 kimi_home.to_str().expect("utf8 path"),
1420 );
1421 let grok_path = temp.path().join("grok-auth.json");
1422 std::fs::write(&grok_path, "{}").expect("empty grok fixture");
1423 let _grok_path = crate::test_support::EnvVarGuard::set(
1424 "GROK_AUTH_PATH",
1425 grok_path.to_str().expect("utf8 path"),
1426 );
1427
1428 let config = crate::config::Config {
1429 providers: Some(crate::config::ProvidersConfig {
1430 moonshot: crate::config::ProviderConfig {
1431 auth_mode: Some("kimi_oauth".to_string()),
1432 ..Default::default()
1433 },
1434 xai: crate::config::ProviderConfig {
1435 auth_mode: Some("oauth".to_string()),
1436 ..Default::default()
1437 },
1438 ..Default::default()
1439 }),
1440 ..Default::default()
1441 };
1442
1443 crate::external_credentials::reset_side_effect_trap();
1444 assert_eq!(
1445 credential_state_for_provider(
1446 &config,
1447 &(config).test_identity_for_kind(ProviderKind::Moonshot)
1448 ),
1449 CredentialState::MissingKey,
1450 "an unusable Kimi import must recover through the supported API-key route"
1451 );
1452 assert_eq!(
1453 credential_state_for_provider(
1454 &config,
1455 &(config).test_identity_for_kind(ProviderKind::Xai)
1456 ),
1457 CredentialState::MissingLogin
1458 );
1459 assert_eq!(
1460 crate::external_credentials::side_effect_trap_counts(),
1461 (0, 0),
1462 "readiness must not inspect external OAuth files without consent"
1463 );
1464 assert_eq!(
1465 std::fs::read_to_string(kimi_home.join("credentials/kimi-code.json"))
1466 .expect("Kimi fixture unchanged"),
1467 "{not-json"
1468 );
1469 assert_eq!(
1470 std::fs::read_to_string(&grok_path).expect("Grok fixture unchanged"),
1471 "{}"
1472 );
1473
1474 let api_key_config = crate::config::Config {
1475 providers: Some(crate::config::ProvidersConfig {
1476 xai: crate::config::ProviderConfig {
1477 api_key: Some("explicit-xai-key".to_string()),
1478 ..Default::default()
1479 },
1480 ..Default::default()
1481 }),
1482 ..Default::default()
1483 };
1484 assert_eq!(
1485 credential_state_for_provider(
1486 &api_key_config,
1487 &(api_key_config).test_identity_for_kind(ProviderKind::Xai)
1488 ),
1489 CredentialState::Saved,
1490 "an unrelated stale Grok OAuth file must not shadow an explicit xAI API key"
1491 );
1492 assert_eq!(
1493 credential_state_for_provider(
1494 &crate::config::Config::default(),
1495 &(crate::config::Config::default()).test_identity_for_kind(ProviderKind::Xai)
1496 ),
1497 CredentialState::MissingKey,
1498 "a Grok file is not active until xAI OAuth is selected in config"
1499 );
1500 let stale_root_config = crate::config::Config {
1501 provider: Some("xai".to_string()),
1502 ..Default::default()
1503 }
1504 .with_legacy_root(Some("legacy-deepseek-root-key".to_string()), None);
1505 assert_eq!(
1506 credential_state_for_provider(
1507 &stale_root_config,
1508 &(stale_root_config).test_identity_for_kind(ProviderKind::Xai)
1509 ),
1510 CredentialState::MissingKey,
1511 "the legacy root DeepSeek key is not an xAI credential"
1512 );
1513
1514 let _cli_source = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
1515 let _cli_key =
1516 crate::test_support::EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
1517 let cli_config = crate::config::Config {
1518 provider: Some("xai".to_string()),
1519 ..Default::default()
1520 };
1521 assert_eq!(
1522 credential_state_for_provider(
1523 &cli_config,
1524 &(cli_config).test_identity_for_kind(ProviderKind::Xai)
1525 ),
1526 CredentialState::Saved,
1527 "the source-marked CLI override is valid for the active xAI provider"
1528 );
1529 }
1530
1531 #[test]
1532 fn custom_provider_env_and_local_no_auth_states_match_runtime() {
1533 let _lock = crate::test_support::lock_test_env();
1534 let _custom_key = crate::test_support::EnvVarGuard::set("ACME_CUSTOM_KEY", "custom-secret");
1535 let remote = crate::config::Config {
1536 provider: Some("acme".to_string()),
1537 providers: Some(crate::config::ProvidersConfig {
1538 custom: std::collections::HashMap::from([(
1539 "acme".to_string(),
1540 crate::config::ProviderConfig {
1541 kind: Some("openai-compatible".to_string()),
1542 base_url: Some("https://api.acme.test/v1".to_string()),
1543 model: Some("acme-coder".to_string()),
1544 api_key_env: Some("ACME_CUSTOM_KEY".to_string()),
1545 ..Default::default()
1546 },
1547 )]),
1548 ..Default::default()
1549 }),
1550 ..Default::default()
1551 };
1552 assert_eq!(
1553 credential_state_for_provider(
1554 &remote,
1555 &(remote).test_identity_for_kind(ProviderKind::Custom)
1556 ),
1557 CredentialState::Saved
1558 );
1559
1560 let local = crate::config::Config {
1561 provider: Some("local-acme".to_string()),
1562 providers: Some(crate::config::ProvidersConfig {
1563 custom: std::collections::HashMap::from([(
1564 "local-acme".to_string(),
1565 crate::config::ProviderConfig {
1566 kind: Some("openai-compatible".to_string()),
1567 base_url: Some("http://127.0.0.1:8080/v1".to_string()),
1568 model: Some("local-model".to_string()),
1569 auth_mode: Some("none".to_string()),
1570 ..Default::default()
1571 },
1572 )]),
1573 ..Default::default()
1574 }),
1575 ..Default::default()
1576 };
1577 assert_eq!(
1578 credential_state_for_provider(
1579 &local,
1580 &(local).test_identity_for_kind(ProviderKind::Custom)
1581 ),
1582 CredentialState::NoAuth
1583 );
1584 assert_eq!(
1585 resolve_for_model(
1586 &local,
1587 &(local).test_identity_for_kind(ProviderKind::Custom),
1588 "local-model",
1589 &ProviderReadinessSnapshot::default(),
1590 ),
1591 ResolvedProviderReadiness::NoAuthUnchecked
1592 );
1593 }
1594
1595 #[test]
1596 fn no_auth_has_distinct_readiness_and_cache_identity_from_implicit_local() {
1597 let local = crate::config::Config {
1598 provider: Some("vllm".to_string()),
1599 providers: Some(crate::config::ProvidersConfig {
1600 vllm: crate::config::ProviderConfig {
1601 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
1602 model: Some("local-model".to_string()),
1603 ..Default::default()
1604 },
1605 ..Default::default()
1606 }),
1607 ..Default::default()
1608 };
1609 let mut no_auth = local.clone();
1610 no_auth
1611 .providers
1612 .as_mut()
1613 .expect("providers")
1614 .vllm
1615 .auth_mode = Some("no-auth".to_string());
1616
1617 assert_eq!(
1618 credential_state_for_provider(
1619 &local,
1620 &(local).test_identity_for_kind(ProviderKind::Vllm)
1621 ),
1622 CredentialState::Local
1623 );
1624 assert_eq!(
1625 credential_state_for_provider(
1626 &no_auth,
1627 &(no_auth).test_identity_for_kind(ProviderKind::Vllm)
1628 ),
1629 CredentialState::NoAuth
1630 );
1631 assert_eq!(
1632 auth_class_for_provider(&local, &(local).test_identity_for_kind(ProviderKind::Vllm)),
1633 ProviderAuthClass::Local
1634 );
1635 assert_eq!(
1636 auth_class_for_provider(
1637 &no_auth,
1638 &(no_auth).test_identity_for_kind(ProviderKind::Vllm)
1639 ),
1640 ProviderAuthClass::NoAuth
1641 );
1642
1643 let mut checks = ProviderReadinessSnapshot::default();
1644 checks.record_success(
1645 &local,
1646 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
1647 &local,
1648 ProviderKind::Vllm,
1649 "local-model",
1650 ),
1651 "local-model",
1652 );
1653 assert_eq!(
1654 resolve_for_model(
1655 &no_auth,
1656 &(no_auth).test_identity_for_kind(ProviderKind::Vllm),
1657 "local-model",
1658 &checks
1659 ),
1660 ResolvedProviderReadiness::NoAuthUnchecked,
1661 "implicit-local success must not verify an explicitly no-auth route"
1662 );
1663 assert!(ResolvedProviderReadiness::NoAuthUnchecked.can_attempt());
1664 assert_eq!(
1665 ResolvedProviderReadiness::NoAuthUnchecked.label(),
1666 "no auth · not checked"
1667 );
1668 }
1669
1670 #[test]
1671 fn ollama_readiness_distinguishes_local_from_cloud_credentials() {
1672 let _lock = crate::test_support::lock_test_env();
1673 let temp = tempfile::tempdir().expect("isolated credential home");
1674 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1675 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1676 let _ollama_cloud_key = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
1677 let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
1678 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1679 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1680
1681 let local = crate::config::Config {
1682 provider: Some("ollama".to_string()),
1683 ..Default::default()
1684 };
1685 assert_eq!(
1686 credential_state_for_provider(
1687 &local,
1688 &(local).test_identity_for_kind(ProviderKind::Ollama)
1689 ),
1690 CredentialState::Local
1691 );
1692 assert_eq!(
1693 auth_class_for_provider(
1694 &local,
1695 &(local).test_identity_for_kind(ProviderKind::Ollama)
1696 ),
1697 ProviderAuthClass::Local
1698 );
1699
1700 let mut cloud = crate::config::Config {
1701 provider: Some("ollama".to_string()),
1702 providers: Some(crate::config::ProvidersConfig {
1703 ollama: crate::config::ProviderConfig {
1704 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
1705 ..Default::default()
1706 },
1707 ..Default::default()
1708 }),
1709 ..Default::default()
1710 };
1711 assert_eq!(
1712 cloud.active_provider_identity().unwrap().provider,
1713 ProviderKind::OllamaCloud
1714 );
1715 assert_eq!(
1716 credential_state_for_provider(
1717 &cloud,
1718 &(cloud).test_identity_for_kind(ProviderKind::OllamaCloud)
1719 ),
1720 CredentialState::MissingKey
1721 );
1722 assert_eq!(
1723 auth_class_for_provider(
1724 &cloud,
1725 &(cloud).test_identity_for_kind(ProviderKind::OllamaCloud)
1726 ),
1727 ProviderAuthClass::ApiKey
1728 );
1729
1730 cloud.providers.as_mut().expect("providers").ollama.api_key =
1731 Some("ollama-cloud-key".to_string());
1732 assert_eq!(
1733 credential_state_for_provider(
1734 &cloud,
1735 &(cloud).test_identity_for_kind(ProviderKind::OllamaCloud)
1736 ),
1737 CredentialState::Saved
1738 );
1739 }
1740
1741 #[test]
1742 fn explicit_api_key_mode_on_loopback_requires_a_real_credential() {
1743 let _lock = crate::test_support::lock_test_env();
1744 let temp = tempfile::tempdir().expect("isolated credential home");
1745 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1746 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1747 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
1748 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1749 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1750
1751 let missing = crate::config::Config {
1752 provider: Some("vllm".to_string()),
1753 providers: Some(crate::config::ProvidersConfig {
1754 vllm: crate::config::ProviderConfig {
1755 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
1756 model: Some("local-model".to_string()),
1757 auth_mode: Some("api_key".to_string()),
1758 ..Default::default()
1759 },
1760 ..Default::default()
1761 }),
1762 ..Default::default()
1763 };
1764 assert_eq!(
1765 credential_state_for_provider(
1766 &missing,
1767 &(missing).test_identity_for_kind(ProviderKind::Vllm)
1768 ),
1769 CredentialState::MissingKey
1770 );
1771 assert!(missing.active_route_api_key().is_err());
1772
1773 let mut configured = missing.clone();
1774 configured
1775 .providers
1776 .as_mut()
1777 .expect("providers")
1778 .vllm
1779 .api_key = Some("protected-local-key".to_string());
1780 assert_eq!(
1781 credential_state_for_provider(
1782 &configured,
1783 &(configured).test_identity_for_kind(ProviderKind::Vllm)
1784 ),
1785 CredentialState::Saved
1786 );
1787 assert_eq!(
1788 configured.active_route_api_key().expect("configured key"),
1789 "protected-local-key"
1790 );
1791
1792 let named_custom = crate::config::Config {
1793 provider: Some("protected-local".to_string()),
1794 providers: Some(crate::config::ProvidersConfig {
1795 custom: std::collections::HashMap::from([(
1796 "protected-local".to_string(),
1797 crate::config::ProviderConfig {
1798 kind: Some("openai-compatible".to_string()),
1799 base_url: Some("http://127.0.0.1:9000/v1".to_string()),
1800 model: Some("private-model".to_string()),
1801 auth_mode: Some("bearer".to_string()),
1802 ..Default::default()
1803 },
1804 )]),
1805 ..Default::default()
1806 }),
1807 ..Default::default()
1808 };
1809 assert_eq!(
1810 credential_state_for_provider(
1811 &named_custom,
1812 &(named_custom).test_identity_for_kind(ProviderKind::Custom)
1813 ),
1814 CredentialState::MissingKey
1815 );
1816 assert!(named_custom.active_route_api_key().is_err());
1817 }
1818
1819 #[test]
1820 fn provider_auth_metadata_is_not_a_runtime_credential() {
1821 let _lock = crate::test_support::lock_test_env();
1822 let temp = tempfile::tempdir().expect("isolated credential home");
1823 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1824 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1825 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
1826 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1827 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1828 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1829
1830 let command = crate::config::Config {
1831 provider: Some("openai".to_string()),
1832 providers: Some(crate::config::ProvidersConfig {
1833 openai: crate::config::ProviderConfig {
1834 auth: Some(codewhale_config::ProviderAuthSourceToml {
1835 source: codewhale_config::AuthSourceKind::Command,
1836 command: vec!["secret-tool".to_string(), "lookup".to_string()],
1837 timeout_ms: Some(2_000),
1838 secret_id: None,
1839 }),
1840 ..Default::default()
1841 },
1842 ..Default::default()
1843 }),
1844 ..Default::default()
1845 };
1846 assert_eq!(
1847 credential_state_for_provider(
1848 &command,
1849 &(command).test_identity_for_kind(ProviderKind::Openai)
1850 ),
1851 CredentialState::MissingKey
1852 );
1853
1854 let secret = crate::config::Config {
1855 provider: Some("xai".to_string()),
1856 providers: Some(crate::config::ProvidersConfig {
1857 xai: crate::config::ProviderConfig {
1858 auth: Some(codewhale_config::ProviderAuthSourceToml {
1859 source: codewhale_config::AuthSourceKind::Secret,
1860 command: Vec::new(),
1861 timeout_ms: None,
1862 secret_id: Some("codewhale/xai".to_string()),
1863 }),
1864 ..Default::default()
1865 },
1866 ..Default::default()
1867 }),
1868 ..Default::default()
1869 };
1870 assert_eq!(
1871 credential_state_for_provider(
1872 &secret,
1873 &(secret).test_identity_for_kind(ProviderKind::Xai)
1874 ),
1875 CredentialState::MissingKey
1876 );
1877 }
1878
1879 #[test]
1880 fn oauth_readiness_is_limited_to_official_endpoints() {
1881 let _lock = crate::test_support::lock_test_env();
1882 let temp = tempfile::tempdir().expect("isolated oauth home");
1883 let missing_grok_auth = temp.path().join("missing-grok-auth.json");
1884 let _grok_auth =
1885 crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &missing_grok_auth);
1886 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1887 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
1888 let _legacy_codex_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
1889
1890 let custom_xai = crate::config::Config {
1891 provider: Some("xai".to_string()),
1892 providers: Some(crate::config::ProvidersConfig {
1893 xai: crate::config::ProviderConfig {
1894 base_url: Some("https://gateway.example.test/v1".to_string()),
1895 auth_mode: Some("oauth".to_string()),
1896 ..Default::default()
1897 },
1898 ..Default::default()
1899 }),
1900 ..Default::default()
1901 };
1902 assert_eq!(
1903 auth_class_for_provider(
1904 &custom_xai,
1905 &(custom_xai).test_identity_for_kind(ProviderKind::Xai)
1906 ),
1907 ProviderAuthClass::ApiKey
1908 );
1909 assert_eq!(
1910 credential_state_for_provider(
1911 &custom_xai,
1912 &(custom_xai).test_identity_for_kind(ProviderKind::Xai)
1913 ),
1914 CredentialState::MissingKey
1915 );
1916
1917 let official_xai = crate::config::Config {
1918 provider: Some("xai".to_string()),
1919 providers: Some(crate::config::ProvidersConfig {
1920 xai: crate::config::ProviderConfig {
1921 auth_mode: Some("oauth".to_string()),
1922 ..Default::default()
1923 },
1924 ..Default::default()
1925 }),
1926 ..Default::default()
1927 };
1928 assert_eq!(
1929 auth_class_for_provider(
1930 &official_xai,
1931 &(official_xai).test_identity_for_kind(ProviderKind::Xai)
1932 ),
1933 ProviderAuthClass::OAuth
1934 );
1935 assert_eq!(
1936 credential_state_for_provider(
1937 &official_xai,
1938 &(official_xai).test_identity_for_kind(ProviderKind::Xai)
1939 ),
1940 CredentialState::MissingLogin
1941 );
1942
1943 let custom_codex = crate::config::Config {
1944 provider: Some("openai-codex".to_string()),
1945 providers: Some(crate::config::ProvidersConfig {
1946 openai_codex: crate::config::ProviderConfig {
1947 base_url: Some("https://gateway.example.test/v1".to_string()),
1948 ..Default::default()
1949 },
1950 ..Default::default()
1951 }),
1952 ..Default::default()
1953 };
1954 assert_eq!(
1955 auth_class_for_provider(
1956 &custom_codex,
1957 &(custom_codex).test_identity_for_kind(ProviderKind::OpenaiCodex)
1958 ),
1959 ProviderAuthClass::ApiKey
1960 );
1961 assert_eq!(
1962 credential_state_for_provider(
1963 &custom_codex,
1964 &(custom_codex).test_identity_for_kind(ProviderKind::OpenaiCodex)
1965 ),
1966 CredentialState::MissingKey
1967 );
1968 }
1969
1970 #[test]
1971 fn xai_custom_endpoint_does_not_count_ambient_official_key() {
1972 let _lock = crate::test_support::lock_test_env();
1973 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1974 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1975 let _ambient = crate::test_support::EnvVarGuard::set("XAI_API_KEY", "ambient-xai-key");
1976 let config = crate::config::Config {
1977 provider: Some("xai".to_string()),
1978 providers: Some(crate::config::ProvidersConfig {
1979 xai: crate::config::ProviderConfig {
1980 base_url: Some("https://unrelated-gateway.example.test/v1".to_string()),
1981 model: Some("private-grok-model".to_string()),
1982 ..Default::default()
1983 },
1984 ..Default::default()
1985 }),
1986 ..Default::default()
1987 };
1988
1989 assert!(!crate::config::has_api_key_for(
1990 &config,
1991 &(config).test_identity_for_kind(ProviderKind::Xai)
1992 ));
1993 assert_eq!(
1994 credential_state_for_provider(
1995 &config,
1996 &(config).test_identity_for_kind(ProviderKind::Xai)
1997 ),
1998 CredentialState::MissingKey
1999 );
2000 }
2001
2002 #[test]
2003 fn active_deepseek_routes_validate_models_against_effective_custom_base_url() {
2004 let official = crate::config::Config::default();
2005 assert!(!route_is_valid_for_model(
2006 &official,
2007 &(official).test_identity_for_kind(ProviderKind::Deepseek),
2008 Some("anthropic/private-model")
2009 ));
2010
2011 for provider_name in ["deepseek", "deepseek-cn"] {
2012 let config = crate::config::Config {
2013 provider: Some(provider_name.to_string()),
2014 default_text_model: Some("anthropic/private-model".to_string()),
2015 ..Default::default()
2016 }
2017 .with_legacy_root(
2018 None,
2019 Some("https://tenant-gateway.example.test/v1".to_string()),
2020 );
2021 let provider = config.active_provider_identity().unwrap().provider;
2022 assert!(matches!(provider, ProviderKind::Deepseek));
2023 assert!(route_is_valid_for_model(
2024 &config,
2025 &(config).test_identity_for_kind(provider),
2026 Some("anthropic/private-model")
2027 ));
2028 }
2029 }
2030
2031 #[test]
2032 fn cli_forwarded_deepseek_custom_route_validates_prefixed_model() {
2033 let _lock = crate::test_support::lock_test_env();
2034 let temp = tempfile::tempdir().expect("isolated config home");
2035 let config_path = temp.path().join("config.toml");
2036 std::fs::write(
2037 &config_path,
2038 r#"api_key = "saved-file-key"
2039 base_url = "https://api.deepseek.com/v1"
2040 default_text_model = "deepseek-chat"
2041 "#,
2042 )
2043 .expect("write config");
2044 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
2045 let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek");
2046 let _legacy_provider = crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROVIDER");
2047 let _base = crate::test_support::EnvVarGuard::set(
2048 "CODEWHALE_BASE_URL",
2049 "https://tenant-gateway.example.test/v1",
2050 );
2051 let _legacy_base = crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL");
2052 let _model =
2053 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "anthropic/private-model");
2054 let _source = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
2055 let _cli_key =
2056 crate::test_support::EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
2057
2058 let config = crate::config::Config::load(Some(config_path), None).expect("load config");
2059 assert_eq!(config.default_model(), "anthropic/private-model");
2060 assert_eq!(
2061 config.active_route_api_key().expect("explicit CLI key"),
2062 "explicit-cli-key"
2063 );
2064 assert!(route_is_valid_for_model(
2065 &config,
2066 &(config).test_identity_for_kind(ProviderKind::Deepseek),
2067 Some("anthropic/private-model")
2068 ));
2069 }
2070
2071 #[test]
2072 fn builtin_loopback_local_and_api_key_routes_have_distinct_cache_identity() {
2073 let _lock = crate::test_support::lock_test_env();
2074 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
2075 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
2076 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
2077 let local = crate::config::Config {
2078 provider: Some("openai".to_string()),
2079 providers: Some(crate::config::ProvidersConfig {
2080 openai: crate::config::ProviderConfig {
2081 base_url: Some("http://127.0.0.1:8080/v1".to_string()),
2082 model: Some("local-model".to_string()),
2083 ..Default::default()
2084 },
2085 ..Default::default()
2086 }),
2087 ..Default::default()
2088 };
2089 let mut protected = local.clone();
2090 let protected_route = &mut protected.providers.as_mut().expect("providers").openai;
2091 protected_route.auth_mode = Some("api_key".to_string());
2092 protected_route.api_key = Some("protected-local-key".to_string());
2093
2094 assert_eq!(
2095 credential_state_for_provider(
2096 &local,
2097 &(local).test_identity_for_kind(ProviderKind::Openai)
2098 ),
2099 CredentialState::Local
2100 );
2101 assert_eq!(
2102 auth_class_for_provider(
2103 &local,
2104 &(local).test_identity_for_kind(ProviderKind::Openai)
2105 ),
2106 ProviderAuthClass::Local
2107 );
2108 assert_eq!(
2109 credential_state_for_provider(
2110 &protected,
2111 &(protected).test_identity_for_kind(ProviderKind::Openai)
2112 ),
2113 CredentialState::Saved
2114 );
2115 assert_eq!(
2116 auth_class_for_provider(
2117 &protected,
2118 &(protected).test_identity_for_kind(ProviderKind::Openai)
2119 ),
2120 ProviderAuthClass::ApiKey
2121 );
2122 assert_ne!(
2123 route_identity_for_model(
2124 &local,
2125 &(local).test_identity_for_kind(ProviderKind::Openai),
2126 "local-model"
2127 ),
2128 route_identity_for_model(
2129 &protected,
2130 &(protected).test_identity_for_kind(ProviderKind::Openai),
2131 "local-model"
2132 )
2133 );
2134
2135 let mut checks = ProviderReadinessSnapshot::default();
2136 checks.record_success(
2137 &local,
2138 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
2139 &local,
2140 ProviderKind::Openai,
2141 "local-model",
2142 ),
2143 "local-model",
2144 );
2145 assert_eq!(
2146 resolve_for_model(
2147 &protected,
2148 &(protected).test_identity_for_kind(ProviderKind::Openai),
2149 "local-model",
2150 &checks
2151 ),
2152 ResolvedProviderReadiness::SavedUnchecked
2153 );
2154
2155 let deepseek_cn_local = crate::config::Config {
2156 provider: Some("deepseek-cn".to_string()),
2157 default_text_model: Some("local-cn-model".to_string()),
2158 ..Default::default()
2159 }
2160 .with_legacy_root(None, Some("http://127.0.0.1:9090/v1".to_string()));
2161 assert_eq!(
2162 credential_state_for_provider(
2163 &deepseek_cn_local,
2164 &(deepseek_cn_local)
2165 .resolve_provider_selection_identity("deepseek-cn")
2166 .unwrap()
2167 ),
2168 CredentialState::Local
2169 );
2170 }
2171 #[test]
2172 fn captured_key_rotation_cannot_reuse_ready_or_failure_evidence() {
2173 let _env = crate::test_support::lock_test_env();
2174 let _cli = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
2175 let mut config = crate::config::Config::from_saved_document(
2176 r#"provider = "openai"
2177 [providers.openai]
2178 api_key = "captured-key"
2179 model = "gpt-5.5"
2180 "#,
2181 None,
2182 )
2183 .unwrap();
2184 let identity = config.active_provider_identity().unwrap();
2185 let endpoint = config.base_url_for_route(&identity);
2186 let receipt = crate::route_receipt::TurnRouteReceipt::from_admitted(
2187 &identity,
2188 "gpt-5.5",
2189 &endpoint,
2190 "captured-key",
2191 );
2192 let mut checks = ProviderReadinessSnapshot::default();
2193 checks.record_success(&config, &receipt, "gpt-5.5");
2194 let route = route_identity_for_model(&config, &identity, "gpt-5.5");
2195 assert!(matches!(
2196 checks.last(&route),
2197 Some(LastProviderCheck::Passed)
2198 ));
2199 config
2200 .set_provider_api_key_override(&identity, Some("replacement-key".into()))
2201 .unwrap();
2202 let replaced = route_identity_for_model(&config, &identity, "gpt-5.5");
2203 assert_ne!(route.credential_generation, replaced.credential_generation);
2204 assert!(checks.last(&replaced).is_none());
2205 checks.record_failure(
2206 &config,
2207 &receipt,
2208 "gpt-5.5",
2209 &ErrorEnvelope::new(
2210 ErrorCategory::Authentication,
2211 ErrorSeverity::Error,
2212 false,
2213 "auth_failed",
2214 "captured route failed",
2215 ),
2216 );
2217 assert!(
2218 checks.last(&replaced).is_none(),
2219 "a late failure uses its dispatched key generation"
2220 );
2221 assert!(matches!(
2222 checks.last(&route),
2223 Some(LastProviderCheck::Failed { .. })
2224 ));
2225 }
2226
2227 #[test]
2228 fn opaque_command_credentials_do_not_execute_or_inherit_cached_readiness() {
2229 let _env = crate::test_support::lock_test_env();
2230 let _cli = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
2231 let mut config = crate::config::Config::from_saved_document(
2232 r#"provider = "openai"
2233 [providers.openai]
2234 api_key = "captured-key"
2235 model = "gpt-5.5"
2236 "#,
2237 None,
2238 )
2239 .unwrap();
2240 let identity = config.active_provider_identity().unwrap();
2241 let endpoint = config.base_url_for_route(&identity);
2242 let receipt = crate::route_receipt::TurnRouteReceipt::from_admitted(
2243 &identity,
2244 "gpt-5.5",
2245 &endpoint,
2246 "captured-key",
2247 );
2248 let mut checks = ProviderReadinessSnapshot::default();
2249 checks.record_success(&config, &receipt, "gpt-5.5");
2250 config.provider_config_for_mut(&identity).unwrap().auth =
2251 Some(codewhale_config::ProviderAuthSourceToml {
2252 source: codewhale_config::AuthSourceKind::Command,
2253 command: vec!["this-command-must-never-run".into()],
2254 timeout_ms: None,
2255 secret_id: None,
2256 });
2257 assert!(
2258 config
2259 .readonly_health_credential_generation(&identity)
2260 .is_none()
2261 );
2262 let opaque = route_identity_for_model(&config, &identity, "gpt-5.5");
2263 assert!(checks.last(&opaque).is_none());
2264 assert!(!format!("{opaque:?}").contains("captured-key"));
2265 assert!(!format!("{receipt:?}").contains("captured-key"));
2266 }
2267
2268 #[test]
2269 fn bound_environment_rotation_and_keyless_endpoint_scope_are_exact() {
2270 let _env = crate::test_support::lock_test_env();
2271 let _cli = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
2272 let config = crate::config::Config::from_saved_document(
2273 r#"provider = "CustomA"
2274 [providers.CustomA]
2275 kind = "openai-compatible"
2276 base_url = "http://127.0.0.1:18180/v1"
2277 model = "private-model"
2278 api_key_env = "CODEWHALE_HEALTH_FIXTURE_KEY"
2279 "#,
2280 None,
2281 )
2282 .unwrap();
2283 let identity = config.active_provider_identity().unwrap();
2284 let first =
2285 crate::test_support::EnvVarGuard::set("CODEWHALE_HEALTH_FIXTURE_KEY", "first-key");
2286 let generation = config
2287 .readonly_health_credential_generation(&identity)
2288 .unwrap();
2289 drop(first);
2290 let _second =
2291 crate::test_support::EnvVarGuard::set("CODEWHALE_HEALTH_FIXTURE_KEY", "second-key");
2292 assert_ne!(
2293 config
2294 .readonly_health_credential_generation(&identity)
2295 .unwrap(),
2296 generation
2297 );
2298 let local = crate::config::Config::from_saved_document(
2299 r#"provider = "CustomA"
2300 [providers.CustomA]
2301 kind = "openai-compatible"
2302 base_url = "http://127.0.0.1:18180/v1"
2303 auth_mode = "none"
2304 "#,
2305 None,
2306 )
2307 .unwrap();
2308 let id = local.active_provider_identity().unwrap();
2309 assert!(local.readonly_health_credential_generation(&id).is_some());
2310 let mut changed = local.clone();
2311 changed
2312 .set_provider_base_url_override(&id, Some("http://127.0.0.1:19191/v1".into()))
2313 .unwrap();
2314 assert_ne!(
2315 local.readonly_health_credential_generation(&id),
2316 changed.readonly_health_credential_generation(&id)
2317 );
2318 }
2319 }
2320
2320 lines RUST