返回 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.deepseek_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::xai_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.deepseek_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 if provider.kind().is_none() {
183 return CredentialState::Legacy;
184 }
185 if provider == ApiProvider::Custom {
186 if config.uses_legacy_literal_custom_route() {
187 if config
188 .base_url
189 .as_deref()
190 .is_some_and(crate::config::base_url_uses_local_host)
191 && !api_key_required
192 {
193 return CredentialState::Local;
194 }
195 return if crate::config::has_api_key_for(config, provider) {
196 CredentialState::Saved
197 } else {
198 CredentialState::MissingKey
199 };
200 }
201 let Some(configured) = config.provider_config_for(provider) else {
202 return CredentialState::MissingKey;
203 };
204 let auth_optional = configured
205 .base_url
206 .as_deref()
207 .is_some_and(crate::config::base_url_uses_local_host)
208 && !api_key_required;
209 if auth_optional {
210 return CredentialState::Local;
211 }
212 let has_auth = (provider == config.api_provider()
213 && crate::config::explicit_cli_api_key_override().is_some())
214 || configured.api_key.as_deref().is_some_and(|value| {
215 crate::config::classify_config_api_key_value(value)
216 == crate::config::ConfigApiKeyValueKind::Literal
217 })
218 || configured
219 .api_key_env
220 .as_deref()
221 .map(str::trim)
222 .filter(|name| !name.is_empty())
223 .is_some_and(|name| {
224 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
225 });
226 return if has_auth {
227 CredentialState::Saved
228 } else {
229 CredentialState::MissingKey
230 };
231 }
232 if provider.is_self_hosted() {
233 return if api_key_required {
234 if crate::config::has_api_key_for(config, provider) {
235 CredentialState::Saved
236 } else {
237 CredentialState::MissingKey
238 }
239 } else {
240 CredentialState::Local
241 };
242 }
243
244 let uses_kimi_imported_token = provider == ApiProvider::Moonshot
245 && official_endpoint
246 && auth_mode
247 .as_deref()
248 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token);
249 if uses_kimi_imported_token {
250 // Kimi remains API-key-only until Codewhale has its own registered
251 // OAuth client identity. Never inspect Kimi CLI storage here.
252 return CredentialState::MissingKey;
253 }
254 if provider == ApiProvider::OpenaiCodex && official_endpoint {
255 return if crate::config::has_api_key_for(config, provider) {
256 CredentialState::Saved
257 } else if provider != config.api_provider()
258 && config.external_credential_read_consent_configured(
259 provider,
260 codewhale_config::ExternalCredentialSource::CodexCli,
261 )
262 {
263 CredentialState::ExternalConsent
264 } else {
265 CredentialState::MissingLogin
266 };
267 }
268 let xai_oauth_selected = provider == ApiProvider::Xai
269 && official_endpoint
270 && auth_mode
271 .as_deref()
272 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth);
273 if xai_oauth_selected {
274 return if crate::xai_oauth::credentials_valid(config)
275 || explicit_provider_credential_present(config, provider)
276 {
277 CredentialState::Saved
278 } else if provider != config.api_provider()
279 && config.external_credential_read_consent_configured(
280 provider,
281 codewhale_config::ExternalCredentialSource::GrokCli,
282 )
283 {
284 CredentialState::ExternalConsent
285 } else {
286 CredentialState::MissingLogin
287 };
288 }
289 if provider == ApiProvider::Xai && explicit_provider_credential_present(config, provider) {
290 return CredentialState::Saved;
291 }
292 if provider == ApiProvider::Xai {
293 return CredentialState::MissingKey;
294 }
295
296 if crate::config::has_api_key_for(config, provider) {
297 CredentialState::Saved
298 } else {
299 CredentialState::MissingKey
300 }
301 }
302
303 fn explicit_provider_credential_present(
304 config: &crate::config::Config,
305 provider: ApiProvider,
306 ) -> bool {
307 (provider == config.api_provider() && crate::config::explicit_cli_api_key_override().is_some())
308 || (!config.provider_uses_custom_endpoint(provider)
309 && provider
310 .env_vars()
311 .iter()
312 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty())))
313 || (config.config_credentials_are_bound_to_provider_endpoint(provider)
314 && config.provider_config_for(provider).is_some_and(|entry| {
315 entry.api_key.as_deref().is_some_and(|value| {
316 crate::config::classify_config_api_key_value(value)
317 == crate::config::ConfigApiKeyValueKind::Literal
318 }) || entry
319 .api_key_env
320 .as_deref()
321 .map(str::trim)
322 .filter(|name| !name.is_empty())
323 .is_some_and(|name| {
324 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
325 })
326 }))
327 }
328
329 /// Validate the configured provider/model/endpoint route without making a
330 /// network request. This is shared by model inventory, `/model`, and Fleet so
331 /// none of them can mark a route selectable when `/provider` would reject it.
332 pub(crate) fn route_is_valid_for_model(
333 config: &crate::config::Config,
334 provider: ApiProvider,
335 model: Option<&str>,
336 ) -> bool {
337 let compatibility_kind =
338 (provider == ApiProvider::DeepseekCN).then_some(codewhale_config::ProviderKind::Deepseek);
339 let Some(kind) = provider.kind().or(compatibility_kind) else {
340 return true;
341 };
342 let configured = config.provider_config_for(provider);
343 let configured_model = model
344 .map(str::trim)
345 .filter(|value| !value.is_empty())
346 .map(str::to_string)
347 .or_else(|| {
348 configured
349 .and_then(|entry| entry.model.as_deref())
350 .map(str::trim)
351 .filter(|value| !value.is_empty())
352 .map(str::to_string)
353 });
354 let active_model = (provider == config.api_provider())
355 .then(|| config.default_model())
356 .filter(|model| !model.trim().is_empty() && !model.eq_ignore_ascii_case("auto"));
357 let request = RouteRequest {
358 explicit_provider: Some(kind),
359 model_selector: configured_model.or(active_model).map(LogicalModelRef::from),
360 saved_provider_model: None,
361 base_url_override: if provider == config.api_provider() {
362 Some(config.deepseek_base_url())
363 } else if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
364 config
365 .base_url
366 .as_deref()
367 .map(str::trim)
368 .filter(|value| !value.is_empty())
369 .map(str::to_string)
370 } else {
371 configured
372 .and_then(|entry| entry.base_url.as_deref())
373 .map(str::trim)
374 .filter(|value| !value.is_empty())
375 .map(str::to_string)
376 },
377 limit_overrides: Vec::new(),
378 };
379 RouteResolver::new()
380 .resolve(&request)
381 .is_ok_and(|candidate| candidate.validation().ok)
382 }
383
384 #[derive(Debug, Clone, PartialEq, Eq)]
385 pub(crate) enum LastProviderCheck {
386 Passed,
387 Failed {
388 category: ErrorCategory,
389 message: String,
390 },
391 }
392
393 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
394 #[serde(rename_all = "snake_case")]
395 pub(crate) enum ResolvedProviderReadiness {
396 MissingKey,
397 MissingLogin,
398 ExternalConsentPendingSelection,
399 SavedUnchecked,
400 ImportedTokenUnchecked,
401 NoAuthUnchecked,
402 LocalUnchecked,
403 Ready,
404 SavedLastCheckFailed {
405 category: ErrorCategory,
406 message: String,
407 },
408 InvalidRoute,
409 Legacy,
410 }
411
412 impl ResolvedProviderReadiness {
413 pub(crate) fn label(&self) -> Cow<'static, str> {
414 match self {
415 Self::MissingKey => Cow::Borrowed("missing key"),
416 Self::MissingLogin => Cow::Borrowed("missing login"),
417 Self::ExternalConsentPendingSelection => {
418 Cow::Borrowed("external consent · select to check")
419 }
420 Self::SavedUnchecked => Cow::Borrowed("key saved · not checked"),
421 Self::ImportedTokenUnchecked => Cow::Borrowed("imported token · not checked"),
422 Self::NoAuthUnchecked => Cow::Borrowed("no auth · not checked"),
423 Self::LocalUnchecked => Cow::Borrowed("local · not checked"),
424 Self::Ready => Cow::Borrowed("ready"),
425 Self::SavedLastCheckFailed { category, .. } => {
426 Cow::Owned(format!("last check failed ({category})"))
427 }
428 Self::InvalidRoute => Cow::Borrowed("invalid route"),
429 Self::Legacy => Cow::Borrowed("legacy"),
430 }
431 }
432
433 pub(crate) fn detail(&self) -> Option<&str> {
434 match self {
435 Self::SavedLastCheckFailed { message, .. } => Some(message),
436 _ => None,
437 }
438 }
439
440 pub(crate) fn can_attempt(&self) -> bool {
441 matches!(
442 self,
443 Self::SavedUnchecked
444 | Self::NoAuthUnchecked
445 | Self::LocalUnchecked
446 | Self::ImportedTokenUnchecked
447 | Self::Ready
448 | Self::SavedLastCheckFailed { .. }
449 )
450 }
451
452 /// Whether the provider needs an explicit human activation step before it
453 /// can be used. Fleet uses this to turn a dormant external-consent route
454 /// into a real selection without weakening the global readiness boundary.
455 pub(crate) fn requires_explicit_activation(&self) -> bool {
456 matches!(self, Self::ExternalConsentPendingSelection)
457 }
458
459 /// A short, non-sensitive reason this row cannot be activated. `None`
460 /// means the row is either ready or requires explicit activation.
461 pub(crate) fn blocked_reason(&self) -> Option<Cow<'static, str>> {
462 match self {
463 Self::MissingKey => Some(Cow::Borrowed("missing API key")),
464 Self::MissingLogin => Some(Cow::Borrowed("missing login")),
465 Self::InvalidRoute => Some(Cow::Borrowed("invalid route")),
466 Self::Legacy => Some(Cow::Borrowed("legacy route")),
467 Self::SavedLastCheckFailed { message, .. } => Some(Cow::Owned(message.clone())),
468 Self::SavedUnchecked
469 | Self::ImportedTokenUnchecked
470 | Self::NoAuthUnchecked
471 | Self::LocalUnchecked
472 | Self::Ready
473 | Self::ExternalConsentPendingSelection => None,
474 }
475 }
476 }
477
478 #[derive(Debug, Clone, Default)]
479 pub(crate) struct ProviderReadinessSnapshot {
480 checks: Vec<(ProviderRouteIdentity, LastProviderCheck)>,
481 }
482
483 impl ProviderReadinessSnapshot {
484 fn last(&self, identity: &ProviderRouteIdentity) -> Option<&LastProviderCheck> {
485 self.checks
486 .iter()
487 .rev()
488 .find_map(|(candidate, check)| (candidate == identity).then_some(check))
489 }
490
491 pub(crate) fn record_success(
492 &mut self,
493 config: &crate::config::Config,
494 provider: ApiProvider,
495 model: &str,
496 ) {
497 self.replace(
498 route_identity_for_model(config, provider, model),
499 LastProviderCheck::Passed,
500 );
501 }
502
503 pub(crate) fn record_failure(
504 &mut self,
505 config: &crate::config::Config,
506 provider: ApiProvider,
507 model: &str,
508 envelope: &ErrorEnvelope,
509 ) {
510 if !provider_owned_failure(envelope) {
511 return;
512 }
513 self.replace(
514 route_identity_for_model(config, provider, model),
515 LastProviderCheck::Failed {
516 category: envelope.category,
517 message: sanitize_message(&envelope.message),
518 },
519 );
520 }
521
522 #[cfg(test)]
523 pub(crate) fn record_failure_message(
524 &mut self,
525 config: &crate::config::Config,
526 provider: ApiProvider,
527 model: &str,
528 category: ErrorCategory,
529 message: &str,
530 ) {
531 self.replace(
532 route_identity_for_model(config, provider, model),
533 LastProviderCheck::Failed {
534 category,
535 message: sanitize_message(message),
536 },
537 );
538 }
539
540 fn replace(&mut self, identity: ProviderRouteIdentity, check: LastProviderCheck) {
541 self.checks.retain(|(candidate, _)| candidate != &identity);
542 self.checks.push((identity, check));
543 }
544 }
545
546 pub(crate) fn resolve_with_identity(
547 identity: &ProviderRouteIdentity,
548 credentials: CredentialState,
549 route_ok: bool,
550 checks: &ProviderReadinessSnapshot,
551 ) -> ResolvedProviderReadiness {
552 if !route_ok {
553 return ResolvedProviderReadiness::InvalidRoute;
554 }
555 match credentials {
556 CredentialState::Legacy => ResolvedProviderReadiness::Legacy,
557 CredentialState::MissingKey => ResolvedProviderReadiness::MissingKey,
558 CredentialState::MissingLogin => ResolvedProviderReadiness::MissingLogin,
559 CredentialState::ExternalConsent => match checks.last(identity) {
560 Some(LastProviderCheck::Passed) => ResolvedProviderReadiness::Ready,
561 Some(LastProviderCheck::Failed { category, message }) => {
562 ResolvedProviderReadiness::SavedLastCheckFailed {
563 category: *category,
564 message: message.clone(),
565 }
566 }
567 None => ResolvedProviderReadiness::ExternalConsentPendingSelection,
568 },
569 CredentialState::Saved
570 | CredentialState::ImportedToken
571 | CredentialState::NoAuth
572 | CredentialState::Local => match checks.last(identity) {
573 Some(LastProviderCheck::Passed) => ResolvedProviderReadiness::Ready,
574 Some(LastProviderCheck::Failed { category, message }) => {
575 ResolvedProviderReadiness::SavedLastCheckFailed {
576 category: *category,
577 message: message.clone(),
578 }
579 }
580 None if credentials == CredentialState::NoAuth => {
581 ResolvedProviderReadiness::NoAuthUnchecked
582 }
583 None if credentials == CredentialState::Local => {
584 ResolvedProviderReadiness::LocalUnchecked
585 }
586 None if credentials == CredentialState::ImportedToken => {
587 ResolvedProviderReadiness::ImportedTokenUnchecked
588 }
589 None => ResolvedProviderReadiness::SavedUnchecked,
590 },
591 }
592 }
593
594 pub(crate) fn resolve_for_model(
595 config: &crate::config::Config,
596 provider: ApiProvider,
597 model: &str,
598 checks: &ProviderReadinessSnapshot,
599 ) -> ResolvedProviderReadiness {
600 resolve_with_identity(
601 &route_identity_for_model(config, provider, model),
602 credential_state_for_provider(config, provider),
603 route_is_valid_for_model(config, provider, Some(model)),
604 checks,
605 )
606 }
607
608 fn provider_owned_failure(envelope: &ErrorEnvelope) -> bool {
609 matches!(
610 envelope.category,
611 ErrorCategory::Network
612 | ErrorCategory::Authentication
613 | ErrorCategory::Authorization
614 | ErrorCategory::RateLimit
615 | ErrorCategory::Timeout
616 )
617 }
618
619 fn sanitize_message(message: &str) -> String {
620 crate::utils::truncate_with_ellipsis(message.trim(), 120, "…")
621 }
622
623 #[cfg(test)]
624 mod tests {
625 use super::*;
626 use crate::error_taxonomy::ErrorSeverity;
627
628 fn resolve_test_route(
629 config: &crate::config::Config,
630 provider: ApiProvider,
631 model: &str,
632 credentials: CredentialState,
633 route_ok: bool,
634 checks: &ProviderReadinessSnapshot,
635 ) -> ResolvedProviderReadiness {
636 resolve_with_identity(
637 &route_identity_for_model(config, provider, model),
638 credentials,
639 route_ok,
640 checks,
641 )
642 }
643
644 #[test]
645 fn saved_credentials_are_never_ready_without_observed_success() {
646 let config = crate::config::Config::default();
647 let checks = ProviderReadinessSnapshot::default();
648 assert_eq!(
649 resolve_test_route(
650 &config,
651 ApiProvider::Deepseek,
652 "deepseek-v4-pro",
653 CredentialState::Saved,
654 true,
655 &checks,
656 ),
657 ResolvedProviderReadiness::SavedUnchecked
658 );
659 }
660
661 #[test]
662 fn deepseek_cn_compatibility_alias_uses_real_key_readiness() {
663 let _lock = crate::test_support::lock_test_env();
664 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
665 let missing = crate::config::Config {
666 provider: Some("deepseek-cn".to_string()),
667 ..Default::default()
668 };
669 assert_eq!(
670 credential_state_for_provider(&missing, ApiProvider::DeepseekCN),
671 CredentialState::MissingKey
672 );
673
674 let configured = crate::config::Config {
675 provider: Some("deepseek-cn".to_string()),
676 providers: Some(crate::config::ProvidersConfig {
677 deepseek_cn: crate::config::ProviderConfig {
678 api_key: Some("deepseek-cn-test-key".to_string()),
679 ..Default::default()
680 },
681 ..Default::default()
682 }),
683 ..Default::default()
684 };
685 assert_eq!(
686 credential_state_for_provider(&configured, ApiProvider::DeepseekCN),
687 CredentialState::Saved
688 );
689 }
690
691 #[test]
692 fn custom_readiness_identity_preserves_case_sensitive_route_parts() {
693 let custom = std::collections::HashMap::from([
694 (
695 "CUSTOM".to_string(),
696 crate::config::ProviderConfig {
697 kind: Some("openai-compatible".to_string()),
698 base_url: Some("https://example.test/TenantA/v1".to_string()),
699 model: Some("Vendor/ModelA".to_string()),
700 api_key: Some("test-key-a".to_string()),
701 ..Default::default()
702 },
703 ),
704 (
705 "custom".to_string(),
706 crate::config::ProviderConfig {
707 kind: Some("openai-compatible".to_string()),
708 base_url: Some("https://example.test/tenanta/v1".to_string()),
709 model: Some("vendor/modela".to_string()),
710 api_key: Some("test-key-b".to_string()),
711 ..Default::default()
712 },
713 ),
714 ]);
715 let upper = crate::config::Config {
716 provider: Some("CUSTOM".to_string()),
717 providers: Some(crate::config::ProvidersConfig {
718 custom: custom.clone(),
719 ..Default::default()
720 }),
721 ..Default::default()
722 };
723 let lower = crate::config::Config {
724 provider: Some("custom".to_string()),
725 providers: Some(crate::config::ProvidersConfig {
726 custom,
727 ..Default::default()
728 }),
729 ..Default::default()
730 };
731 let upper_identity = route_identity_for_model(&upper, ApiProvider::Custom, "Vendor/ModelA");
732 let lower_identity = route_identity_for_model(&lower, ApiProvider::Custom, "vendor/modela");
733
734 assert_ne!(upper_identity, lower_identity);
735 assert_eq!(upper_identity.provider_id, "CUSTOM");
736 assert_eq!(upper_identity.endpoint, "https://example.test/TenantA/v1");
737 assert_eq!(upper_identity.model, "Vendor/ModelA");
738
739 let mut checks = ProviderReadinessSnapshot::default();
740 checks.record_success(&upper, ApiProvider::Custom, "Vendor/ModelA");
741 assert_eq!(
742 resolve_for_model(&lower, ApiProvider::Custom, "vendor/modela", &checks),
743 ResolvedProviderReadiness::SavedUnchecked
744 );
745 }
746
747 #[test]
748 fn external_consent_with_passed_check_becomes_ready() {
749 let config = crate::config::Config::default();
750 let mut checks = ProviderReadinessSnapshot::default();
751 checks.record_success(
752 &config,
753 ApiProvider::OpenaiCodex,
754 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
755 );
756 assert_eq!(
757 resolve_test_route(
758 &config,
759 ApiProvider::OpenaiCodex,
760 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
761 CredentialState::ExternalConsent,
762 true,
763 &checks,
764 ),
765 ResolvedProviderReadiness::Ready
766 );
767 }
768
769 #[test]
770 fn external_consent_with_failed_check_surfaces_reason() {
771 let config = crate::config::Config::default();
772 let mut checks = ProviderReadinessSnapshot::default();
773 checks.record_failure_message(
774 &config,
775 ApiProvider::Xai,
776 "grok-4.5",
777 ErrorCategory::Authentication,
778 "consent revoked",
779 );
780 assert!(
781 matches!(
782 resolve_test_route(
783 &config,
784 ApiProvider::Xai,
785 "grok-4.5",
786 CredentialState::ExternalConsent,
787 true,
788 &checks,
789 ),
790 ResolvedProviderReadiness::SavedLastCheckFailed { category, .. }
791 if category == ErrorCategory::Authentication
792 ),
793 "failed activation should surface the sanitized reason"
794 );
795 }
796
797 #[test]
798 fn external_consent_without_check_stays_pending_selection() {
799 let config = crate::config::Config::default();
800 let checks = ProviderReadinessSnapshot::default();
801 assert_eq!(
802 resolve_test_route(
803 &config,
804 ApiProvider::OpenaiCodex,
805 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
806 CredentialState::ExternalConsent,
807 true,
808 &checks,
809 ),
810 ResolvedProviderReadiness::ExternalConsentPendingSelection
811 );
812 }
813
814 #[test]
815 fn success_and_provider_failure_replace_session_evidence() {
816 let config = crate::config::Config::default();
817 let mut checks = ProviderReadinessSnapshot::default();
818 checks.record_success(&config, ApiProvider::Zai, "glm-5.2");
819 assert_eq!(
820 resolve_test_route(
821 &config,
822 ApiProvider::Zai,
823 "glm-5.2",
824 CredentialState::Saved,
825 true,
826 &checks,
827 ),
828 ResolvedProviderReadiness::Ready
829 );
830
831 checks.record_failure(
832 &config,
833 ApiProvider::Zai,
834 "glm-5.2",
835 &ErrorEnvelope::new(
836 ErrorCategory::Authentication,
837 ErrorSeverity::Error,
838 false,
839 "auth_failed",
840 "token rejected",
841 ),
842 );
843 let resolved = resolve_test_route(
844 &config,
845 ApiProvider::Zai,
846 "glm-5.2",
847 CredentialState::Saved,
848 true,
849 &checks,
850 );
851 assert!(matches!(
852 resolved,
853 ResolvedProviderReadiness::SavedLastCheckFailed {
854 category: ErrorCategory::Authentication,
855 ..
856 }
857 ));
858 assert!(resolved.can_attempt());
859 }
860
861 #[test]
862 fn tool_failures_do_not_poison_provider_health() {
863 let config = crate::config::Config::default();
864 let mut checks = ProviderReadinessSnapshot::default();
865 checks.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro");
866 checks.record_failure(
867 &config,
868 ApiProvider::Deepseek,
869 "deepseek-v4-pro",
870 &ErrorEnvelope::new(
871 ErrorCategory::Tool,
872 ErrorSeverity::Error,
873 false,
874 "tool_failed",
875 "shell failed",
876 ),
877 );
878 assert!(matches!(
879 resolve_test_route(
880 &config,
881 ApiProvider::Deepseek,
882 "deepseek-v4-pro",
883 CredentialState::Saved,
884 true,
885 &checks,
886 ),
887 ResolvedProviderReadiness::Ready
888 ));
889 }
890
891 #[test]
892 fn route_and_missing_auth_states_dominate_health() {
893 let config = crate::config::Config {
894 provider: Some("openai-codex".to_string()),
895 ..Default::default()
896 };
897 let mut checks = ProviderReadinessSnapshot::default();
898 checks.record_success(&config, ApiProvider::OpenaiCodex, "gpt-5.5");
899 assert_eq!(
900 resolve_test_route(
901 &config,
902 ApiProvider::OpenaiCodex,
903 "gpt-5.5",
904 CredentialState::MissingLogin,
905 true,
906 &checks
907 ),
908 ResolvedProviderReadiness::MissingLogin
909 );
910 assert_eq!(
911 resolve_test_route(
912 &config,
913 ApiProvider::OpenaiCodex,
914 "gpt-5.5",
915 CredentialState::Saved,
916 false,
917 &checks
918 ),
919 ResolvedProviderReadiness::InvalidRoute
920 );
921 }
922
923 #[test]
924 fn api_key_success_does_not_verify_new_xai_oauth_route() {
925 let _lock = crate::test_support::lock_test_env();
926 let temp = tempfile::tempdir().expect("oauth fixture root");
927 let oauth_path = temp.path().join("grok-auth.json");
928 std::fs::write(
929 &oauth_path,
930 serde_json::to_vec(&serde_json::json!({
931 "test-scope": {
932 "key": "expired-access-token",
933 "refresh_token": "saved-refresh-token",
934 "expires_at": "2000-01-01T00:00:00Z",
935 "auth_mode": "oidc"
936 }
937 }))
938 .expect("oauth json"),
939 )
940 .expect("oauth fixture");
941 let _oauth_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &oauth_path);
942
943 let api_key_config = crate::config::Config {
944 provider: Some("xai".to_string()),
945 providers: Some(crate::config::ProvidersConfig {
946 xai: crate::config::ProviderConfig {
947 api_key: Some("xai-test-key".to_string()),
948 auth_mode: Some("api_key".to_string()),
949 ..Default::default()
950 },
951 ..Default::default()
952 }),
953 ..Default::default()
954 };
955 let mut checks = ProviderReadinessSnapshot::default();
956 let api_key_model = api_key_config.default_model();
957 checks.record_success(&api_key_config, ApiProvider::Xai, &api_key_model);
958
959 let mut oauth_config = api_key_config;
960 oauth_config
961 .providers
962 .as_mut()
963 .expect("providers")
964 .xai
965 .auth_mode = Some("oauth".to_string());
966 assert_eq!(
967 credential_state_for_provider(&oauth_config, ApiProvider::Xai),
968 CredentialState::Saved,
969 "fixture must have structurally valid OAuth material"
970 );
971 let model = oauth_config.default_model();
972 assert_eq!(
973 resolve_for_model(&oauth_config, ApiProvider::Xai, &model, &checks),
974 ResolvedProviderReadiness::SavedUnchecked,
975 "API-key evidence must not cross the auth-class boundary"
976 );
977 }
978
979 #[test]
980 fn observed_success_is_scoped_to_exact_model_endpoint_and_custom_provider() {
981 let deepseek = crate::config::Config {
982 api_key: Some("deepseek-test-key".to_string()),
983 ..Default::default()
984 };
985 let mut checks = ProviderReadinessSnapshot::default();
986 checks.record_success(&deepseek, ApiProvider::Deepseek, "deepseek-v4-pro");
987 assert_eq!(
988 resolve_for_model(
989 &deepseek,
990 ApiProvider::Deepseek,
991 "deepseek-v4-flash",
992 &checks,
993 ),
994 ResolvedProviderReadiness::SavedUnchecked,
995 "one model entitlement must not verify a sibling model"
996 );
997
998 let custom_config = |id: &str, endpoint: &str| crate::config::Config {
999 provider: Some(id.to_string()),
1000 providers: Some(crate::config::ProvidersConfig {
1001 custom: std::collections::HashMap::from([(
1002 id.to_string(),
1003 crate::config::ProviderConfig {
1004 kind: Some("openai-compatible".to_string()),
1005 base_url: Some(endpoint.to_string()),
1006 model: Some("private-coder".to_string()),
1007 api_key: Some("custom-test-key".to_string()),
1008 ..Default::default()
1009 },
1010 )]),
1011 ..Default::default()
1012 }),
1013 ..Default::default()
1014 };
1015 let alpha = custom_config("alpha", "https://alpha.example/v1");
1016 checks.record_success(&alpha, ApiProvider::Custom, "private-coder");
1017
1018 let beta = custom_config("beta", "https://alpha.example/v1");
1019 assert_eq!(
1020 resolve_for_model(&beta, ApiProvider::Custom, "private-coder", &checks),
1021 ResolvedProviderReadiness::SavedUnchecked,
1022 "named custom providers must not share observed health"
1023 );
1024
1025 let alpha_moved = custom_config("alpha", "https://other.example/v1");
1026 assert_eq!(
1027 resolve_for_model(&alpha_moved, ApiProvider::Custom, "private-coder", &checks,),
1028 ResolvedProviderReadiness::SavedUnchecked,
1029 "changing endpoints must invalidate observed health"
1030 );
1031 }
1032
1033 #[test]
1034 fn disabled_external_imports_are_not_probed_by_readiness() {
1035 let _lock = crate::test_support::lock_test_env();
1036 let temp = tempfile::tempdir().expect("oauth fixture root");
1037 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1038 let kimi_home = temp.path().join("kimi");
1039 std::fs::create_dir_all(kimi_home.join("credentials")).expect("kimi credentials dir");
1040 std::fs::write(kimi_home.join("credentials/kimi-code.json"), "{not-json")
1041 .expect("malformed kimi fixture");
1042 let _kimi_home = crate::test_support::EnvVarGuard::set(
1043 "KIMI_CODE_HOME",
1044 kimi_home.to_str().expect("utf8 path"),
1045 );
1046 let grok_path = temp.path().join("grok-auth.json");
1047 std::fs::write(&grok_path, "{}").expect("empty grok fixture");
1048 let _grok_path = crate::test_support::EnvVarGuard::set(
1049 "GROK_AUTH_PATH",
1050 grok_path.to_str().expect("utf8 path"),
1051 );
1052
1053 let config = crate::config::Config {
1054 providers: Some(crate::config::ProvidersConfig {
1055 moonshot: crate::config::ProviderConfig {
1056 auth_mode: Some("kimi_oauth".to_string()),
1057 ..Default::default()
1058 },
1059 xai: crate::config::ProviderConfig {
1060 auth_mode: Some("oauth".to_string()),
1061 ..Default::default()
1062 },
1063 ..Default::default()
1064 }),
1065 ..Default::default()
1066 };
1067
1068 crate::external_credentials::reset_side_effect_trap();
1069 assert_eq!(
1070 credential_state_for_provider(&config, ApiProvider::Moonshot),
1071 CredentialState::MissingKey,
1072 "an unusable Kimi import must recover through the supported API-key route"
1073 );
1074 assert_eq!(
1075 credential_state_for_provider(&config, ApiProvider::Xai),
1076 CredentialState::MissingLogin
1077 );
1078 assert_eq!(
1079 crate::external_credentials::side_effect_trap_counts(),
1080 (0, 0),
1081 "readiness must not inspect external OAuth files without consent"
1082 );
1083 assert_eq!(
1084 std::fs::read_to_string(kimi_home.join("credentials/kimi-code.json"))
1085 .expect("Kimi fixture unchanged"),
1086 "{not-json"
1087 );
1088 assert_eq!(
1089 std::fs::read_to_string(&grok_path).expect("Grok fixture unchanged"),
1090 "{}"
1091 );
1092
1093 let api_key_config = crate::config::Config {
1094 providers: Some(crate::config::ProvidersConfig {
1095 xai: crate::config::ProviderConfig {
1096 api_key: Some("explicit-xai-key".to_string()),
1097 ..Default::default()
1098 },
1099 ..Default::default()
1100 }),
1101 ..Default::default()
1102 };
1103 assert_eq!(
1104 credential_state_for_provider(&api_key_config, ApiProvider::Xai),
1105 CredentialState::Saved,
1106 "an unrelated stale Grok OAuth file must not shadow an explicit xAI API key"
1107 );
1108 assert_eq!(
1109 credential_state_for_provider(&crate::config::Config::default(), ApiProvider::Xai),
1110 CredentialState::MissingKey,
1111 "a Grok file is not active until xAI OAuth is selected in config"
1112 );
1113 let stale_root_config = crate::config::Config {
1114 provider: Some("xai".to_string()),
1115 api_key: Some("legacy-deepseek-root-key".to_string()),
1116 ..Default::default()
1117 };
1118 assert_eq!(
1119 credential_state_for_provider(&stale_root_config, ApiProvider::Xai),
1120 CredentialState::MissingKey,
1121 "the legacy root DeepSeek key is not an xAI credential"
1122 );
1123
1124 let _cli_source = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
1125 let _cli_key =
1126 crate::test_support::EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
1127 let cli_config = crate::config::Config {
1128 provider: Some("xai".to_string()),
1129 ..Default::default()
1130 };
1131 assert_eq!(
1132 credential_state_for_provider(&cli_config, ApiProvider::Xai),
1133 CredentialState::Saved,
1134 "the source-marked CLI override is valid for the active xAI provider"
1135 );
1136 }
1137
1138 #[test]
1139 fn custom_provider_env_and_local_no_auth_states_match_runtime() {
1140 let _lock = crate::test_support::lock_test_env();
1141 let _custom_key = crate::test_support::EnvVarGuard::set("ACME_CUSTOM_KEY", "custom-secret");
1142 let remote = crate::config::Config {
1143 provider: Some("acme".to_string()),
1144 providers: Some(crate::config::ProvidersConfig {
1145 custom: std::collections::HashMap::from([(
1146 "acme".to_string(),
1147 crate::config::ProviderConfig {
1148 kind: Some("openai-compatible".to_string()),
1149 base_url: Some("https://api.acme.test/v1".to_string()),
1150 model: Some("acme-coder".to_string()),
1151 api_key_env: Some("ACME_CUSTOM_KEY".to_string()),
1152 ..Default::default()
1153 },
1154 )]),
1155 ..Default::default()
1156 }),
1157 ..Default::default()
1158 };
1159 assert_eq!(
1160 credential_state_for_provider(&remote, ApiProvider::Custom),
1161 CredentialState::Saved
1162 );
1163
1164 let local = crate::config::Config {
1165 provider: Some("local-acme".to_string()),
1166 providers: Some(crate::config::ProvidersConfig {
1167 custom: std::collections::HashMap::from([(
1168 "local-acme".to_string(),
1169 crate::config::ProviderConfig {
1170 kind: Some("openai-compatible".to_string()),
1171 base_url: Some("http://127.0.0.1:8080/v1".to_string()),
1172 model: Some("local-model".to_string()),
1173 auth_mode: Some("none".to_string()),
1174 ..Default::default()
1175 },
1176 )]),
1177 ..Default::default()
1178 }),
1179 ..Default::default()
1180 };
1181 assert_eq!(
1182 credential_state_for_provider(&local, ApiProvider::Custom),
1183 CredentialState::NoAuth
1184 );
1185 assert_eq!(
1186 resolve_for_model(
1187 &local,
1188 ApiProvider::Custom,
1189 "local-model",
1190 &ProviderReadinessSnapshot::default(),
1191 ),
1192 ResolvedProviderReadiness::NoAuthUnchecked
1193 );
1194 }
1195
1196 #[test]
1197 fn no_auth_has_distinct_readiness_and_cache_identity_from_implicit_local() {
1198 let local = crate::config::Config {
1199 provider: Some("vllm".to_string()),
1200 providers: Some(crate::config::ProvidersConfig {
1201 vllm: crate::config::ProviderConfig {
1202 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
1203 model: Some("local-model".to_string()),
1204 ..Default::default()
1205 },
1206 ..Default::default()
1207 }),
1208 ..Default::default()
1209 };
1210 let mut no_auth = local.clone();
1211 no_auth
1212 .providers
1213 .as_mut()
1214 .expect("providers")
1215 .vllm
1216 .auth_mode = Some("no-auth".to_string());
1217
1218 assert_eq!(
1219 credential_state_for_provider(&local, ApiProvider::Vllm),
1220 CredentialState::Local
1221 );
1222 assert_eq!(
1223 credential_state_for_provider(&no_auth, ApiProvider::Vllm),
1224 CredentialState::NoAuth
1225 );
1226 assert_eq!(
1227 auth_class_for_provider(&local, ApiProvider::Vllm),
1228 ProviderAuthClass::Local
1229 );
1230 assert_eq!(
1231 auth_class_for_provider(&no_auth, ApiProvider::Vllm),
1232 ProviderAuthClass::NoAuth
1233 );
1234
1235 let mut checks = ProviderReadinessSnapshot::default();
1236 checks.record_success(&local, ApiProvider::Vllm, "local-model");
1237 assert_eq!(
1238 resolve_for_model(&no_auth, ApiProvider::Vllm, "local-model", &checks),
1239 ResolvedProviderReadiness::NoAuthUnchecked,
1240 "implicit-local success must not verify an explicitly no-auth route"
1241 );
1242 assert!(ResolvedProviderReadiness::NoAuthUnchecked.can_attempt());
1243 assert_eq!(
1244 ResolvedProviderReadiness::NoAuthUnchecked.label(),
1245 "no auth · not checked"
1246 );
1247 }
1248
1249 #[test]
1250 fn explicit_api_key_mode_on_loopback_requires_a_real_credential() {
1251 let _lock = crate::test_support::lock_test_env();
1252 let temp = tempfile::tempdir().expect("isolated credential home");
1253 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1254 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1255 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
1256 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1257 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1258
1259 let missing = crate::config::Config {
1260 provider: Some("vllm".to_string()),
1261 providers: Some(crate::config::ProvidersConfig {
1262 vllm: crate::config::ProviderConfig {
1263 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
1264 model: Some("local-model".to_string()),
1265 auth_mode: Some("api_key".to_string()),
1266 ..Default::default()
1267 },
1268 ..Default::default()
1269 }),
1270 ..Default::default()
1271 };
1272 assert_eq!(
1273 credential_state_for_provider(&missing, ApiProvider::Vllm),
1274 CredentialState::MissingKey
1275 );
1276 assert!(missing.deepseek_api_key().is_err());
1277
1278 let mut configured = missing.clone();
1279 configured
1280 .providers
1281 .as_mut()
1282 .expect("providers")
1283 .vllm
1284 .api_key = Some("protected-local-key".to_string());
1285 assert_eq!(
1286 credential_state_for_provider(&configured, ApiProvider::Vllm),
1287 CredentialState::Saved
1288 );
1289 assert_eq!(
1290 configured.deepseek_api_key().expect("configured key"),
1291 "protected-local-key"
1292 );
1293
1294 let named_custom = crate::config::Config {
1295 provider: Some("protected-local".to_string()),
1296 providers: Some(crate::config::ProvidersConfig {
1297 custom: std::collections::HashMap::from([(
1298 "protected-local".to_string(),
1299 crate::config::ProviderConfig {
1300 kind: Some("openai-compatible".to_string()),
1301 base_url: Some("http://127.0.0.1:9000/v1".to_string()),
1302 model: Some("private-model".to_string()),
1303 auth_mode: Some("bearer".to_string()),
1304 ..Default::default()
1305 },
1306 )]),
1307 ..Default::default()
1308 }),
1309 ..Default::default()
1310 };
1311 assert_eq!(
1312 credential_state_for_provider(&named_custom, ApiProvider::Custom),
1313 CredentialState::MissingKey
1314 );
1315 assert!(named_custom.deepseek_api_key().is_err());
1316 }
1317
1318 #[test]
1319 fn provider_auth_metadata_is_not_a_runtime_credential() {
1320 let _lock = crate::test_support::lock_test_env();
1321 let temp = tempfile::tempdir().expect("isolated credential home");
1322 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1323 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
1324 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
1325 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1326 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1327 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1328
1329 let command = crate::config::Config {
1330 provider: Some("openai".to_string()),
1331 providers: Some(crate::config::ProvidersConfig {
1332 openai: crate::config::ProviderConfig {
1333 auth: Some(codewhale_config::ProviderAuthSourceToml {
1334 source: codewhale_config::AuthSourceKind::Command,
1335 command: vec!["secret-tool".to_string(), "lookup".to_string()],
1336 timeout_ms: Some(2_000),
1337 secret_id: None,
1338 }),
1339 ..Default::default()
1340 },
1341 ..Default::default()
1342 }),
1343 ..Default::default()
1344 };
1345 assert_eq!(
1346 credential_state_for_provider(&command, ApiProvider::Openai),
1347 CredentialState::MissingKey
1348 );
1349
1350 let secret = crate::config::Config {
1351 provider: Some("xai".to_string()),
1352 providers: Some(crate::config::ProvidersConfig {
1353 xai: crate::config::ProviderConfig {
1354 auth: Some(codewhale_config::ProviderAuthSourceToml {
1355 source: codewhale_config::AuthSourceKind::Secret,
1356 command: Vec::new(),
1357 timeout_ms: None,
1358 secret_id: Some("codewhale/xai".to_string()),
1359 }),
1360 ..Default::default()
1361 },
1362 ..Default::default()
1363 }),
1364 ..Default::default()
1365 };
1366 assert_eq!(
1367 credential_state_for_provider(&secret, ApiProvider::Xai),
1368 CredentialState::MissingKey
1369 );
1370 }
1371
1372 #[test]
1373 fn oauth_readiness_is_limited_to_official_endpoints() {
1374 let _lock = crate::test_support::lock_test_env();
1375 let temp = tempfile::tempdir().expect("isolated oauth home");
1376 let missing_grok_auth = temp.path().join("missing-grok-auth.json");
1377 let _grok_auth =
1378 crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &missing_grok_auth);
1379 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1380 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
1381 let _legacy_codex_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
1382
1383 let custom_xai = crate::config::Config {
1384 provider: Some("xai".to_string()),
1385 providers: Some(crate::config::ProvidersConfig {
1386 xai: crate::config::ProviderConfig {
1387 base_url: Some("https://gateway.example.test/v1".to_string()),
1388 auth_mode: Some("oauth".to_string()),
1389 ..Default::default()
1390 },
1391 ..Default::default()
1392 }),
1393 ..Default::default()
1394 };
1395 assert_eq!(
1396 auth_class_for_provider(&custom_xai, ApiProvider::Xai),
1397 ProviderAuthClass::ApiKey
1398 );
1399 assert_eq!(
1400 credential_state_for_provider(&custom_xai, ApiProvider::Xai),
1401 CredentialState::MissingKey
1402 );
1403
1404 let official_xai = crate::config::Config {
1405 provider: Some("xai".to_string()),
1406 providers: Some(crate::config::ProvidersConfig {
1407 xai: crate::config::ProviderConfig {
1408 auth_mode: Some("oauth".to_string()),
1409 ..Default::default()
1410 },
1411 ..Default::default()
1412 }),
1413 ..Default::default()
1414 };
1415 assert_eq!(
1416 auth_class_for_provider(&official_xai, ApiProvider::Xai),
1417 ProviderAuthClass::OAuth
1418 );
1419 assert_eq!(
1420 credential_state_for_provider(&official_xai, ApiProvider::Xai),
1421 CredentialState::MissingLogin
1422 );
1423
1424 let custom_codex = crate::config::Config {
1425 provider: Some("openai-codex".to_string()),
1426 providers: Some(crate::config::ProvidersConfig {
1427 openai_codex: crate::config::ProviderConfig {
1428 base_url: Some("https://gateway.example.test/v1".to_string()),
1429 ..Default::default()
1430 },
1431 ..Default::default()
1432 }),
1433 ..Default::default()
1434 };
1435 assert_eq!(
1436 auth_class_for_provider(&custom_codex, ApiProvider::OpenaiCodex),
1437 ProviderAuthClass::ApiKey
1438 );
1439 assert_eq!(
1440 credential_state_for_provider(&custom_codex, ApiProvider::OpenaiCodex),
1441 CredentialState::MissingKey
1442 );
1443 }
1444
1445 #[test]
1446 fn xai_custom_endpoint_does_not_count_ambient_official_key() {
1447 let _lock = crate::test_support::lock_test_env();
1448 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1449 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1450 let _ambient = crate::test_support::EnvVarGuard::set("XAI_API_KEY", "ambient-xai-key");
1451 let config = crate::config::Config {
1452 provider: Some("xai".to_string()),
1453 providers: Some(crate::config::ProvidersConfig {
1454 xai: crate::config::ProviderConfig {
1455 base_url: Some("https://unrelated-gateway.example.test/v1".to_string()),
1456 model: Some("private-grok-model".to_string()),
1457 ..Default::default()
1458 },
1459 ..Default::default()
1460 }),
1461 ..Default::default()
1462 };
1463
1464 assert!(!crate::config::has_api_key_for(&config, ApiProvider::Xai));
1465 assert_eq!(
1466 credential_state_for_provider(&config, ApiProvider::Xai),
1467 CredentialState::MissingKey
1468 );
1469 }
1470
1471 #[test]
1472 fn active_deepseek_routes_validate_models_against_effective_custom_base_url() {
1473 let official = crate::config::Config::default();
1474 assert!(!route_is_valid_for_model(
1475 &official,
1476 ApiProvider::Deepseek,
1477 Some("anthropic/private-model")
1478 ));
1479
1480 for provider_name in ["deepseek", "deepseek-cn"] {
1481 let config = crate::config::Config {
1482 provider: Some(provider_name.to_string()),
1483 base_url: Some("https://tenant-gateway.example.test/v1".to_string()),
1484 default_text_model: Some("anthropic/private-model".to_string()),
1485 ..Default::default()
1486 };
1487 let provider = config.api_provider();
1488 assert!(matches!(
1489 provider,
1490 ApiProvider::Deepseek | ApiProvider::DeepseekCN
1491 ));
1492 assert!(route_is_valid_for_model(
1493 &config,
1494 provider,
1495 Some("anthropic/private-model")
1496 ));
1497 }
1498 }
1499
1500 #[test]
1501 fn cli_forwarded_deepseek_custom_route_validates_prefixed_model() {
1502 let _lock = crate::test_support::lock_test_env();
1503 let temp = tempfile::tempdir().expect("isolated config home");
1504 let config_path = temp.path().join("config.toml");
1505 std::fs::write(
1506 &config_path,
1507 r#"api_key = "saved-file-key"
1508 base_url = "https://api.deepseek.com/v1"
1509 default_text_model = "deepseek-chat"
1510 "#,
1511 )
1512 .expect("write config");
1513 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1514 let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek");
1515 let _legacy_provider = crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROVIDER");
1516 let _base = crate::test_support::EnvVarGuard::set(
1517 "CODEWHALE_BASE_URL",
1518 "https://tenant-gateway.example.test/v1",
1519 );
1520 let _legacy_base = crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL");
1521 let _model =
1522 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "anthropic/private-model");
1523 let _source = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
1524 let _cli_key =
1525 crate::test_support::EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
1526
1527 let config = crate::config::Config::load(Some(config_path), None).expect("load config");
1528 assert_eq!(config.default_model(), "anthropic/private-model");
1529 assert_eq!(
1530 config.deepseek_api_key().expect("explicit CLI key"),
1531 "explicit-cli-key"
1532 );
1533 assert!(route_is_valid_for_model(
1534 &config,
1535 ApiProvider::Deepseek,
1536 Some("anthropic/private-model")
1537 ));
1538 }
1539
1540 #[test]
1541 fn builtin_loopback_local_and_api_key_routes_have_distinct_cache_identity() {
1542 let _lock = crate::test_support::lock_test_env();
1543 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
1544 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
1545 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1546 let local = crate::config::Config {
1547 provider: Some("openai".to_string()),
1548 providers: Some(crate::config::ProvidersConfig {
1549 openai: crate::config::ProviderConfig {
1550 base_url: Some("http://127.0.0.1:8080/v1".to_string()),
1551 model: Some("local-model".to_string()),
1552 ..Default::default()
1553 },
1554 ..Default::default()
1555 }),
1556 ..Default::default()
1557 };
1558 let mut protected = local.clone();
1559 let protected_route = &mut protected.providers.as_mut().expect("providers").openai;
1560 protected_route.auth_mode = Some("api_key".to_string());
1561 protected_route.api_key = Some("protected-local-key".to_string());
1562
1563 assert_eq!(
1564 credential_state_for_provider(&local, ApiProvider::Openai),
1565 CredentialState::Local
1566 );
1567 assert_eq!(
1568 auth_class_for_provider(&local, ApiProvider::Openai),
1569 ProviderAuthClass::Local
1570 );
1571 assert_eq!(
1572 credential_state_for_provider(&protected, ApiProvider::Openai),
1573 CredentialState::Saved
1574 );
1575 assert_eq!(
1576 auth_class_for_provider(&protected, ApiProvider::Openai),
1577 ProviderAuthClass::ApiKey
1578 );
1579 assert_ne!(
1580 route_identity_for_model(&local, ApiProvider::Openai, "local-model"),
1581 route_identity_for_model(&protected, ApiProvider::Openai, "local-model")
1582 );
1583
1584 let mut checks = ProviderReadinessSnapshot::default();
1585 checks.record_success(&local, ApiProvider::Openai, "local-model");
1586 assert_eq!(
1587 resolve_for_model(&protected, ApiProvider::Openai, "local-model", &checks),
1588 ResolvedProviderReadiness::SavedUnchecked
1589 );
1590
1591 let deepseek_cn_local = crate::config::Config {
1592 provider: Some("deepseek-cn".to_string()),
1593 base_url: Some("http://127.0.0.1:9090/v1".to_string()),
1594 default_text_model: Some("local-cn-model".to_string()),
1595 ..Default::default()
1596 };
1597 assert_eq!(
1598 credential_state_for_provider(&deepseek_cn_local, ApiProvider::DeepseekCN),
1599 CredentialState::Local
1600 );
1601 }
1602 }
1603
1603 lines RUST