返回 CodeWhale
provider_picker.rs
根目录 / crates / tui / src / tui / provider_picker.rs
1 //! `/provider` picker modal — pick a provider (DeepSeek / NVIDIA NIM /
2 //! hosted providers / self-hosted providers) and, if it lacks credentials, type the API key
3 //! inline before completing the switch (#52).
4 //!
5 //! The picker is intentionally a single modal with guided stages (#3875):
6 //!
7 //! 1. **List** — pick a provider; each row shows the active provider arrow
8 //! and an "API key configured" / "needs API key" hint. Enter on a
9 //! configured provider applies the switch immediately
10 //! ([`ViewEvent::ProviderPickerApplied`]). Enter on an un-configured one
11 //! transitions the same modal into the key-entry state.
12 //! 2. **Key entry** — masked input box pre-filled with the provider's
13 //! canonical env-var name as a hint. Enter submits
14 //! [`ViewEvent::ProviderPickerApiKeySubmitted`] for live validation.
15 //! Failed verification reopens this stage with the provider error and
16 //! never persists the rejected secret.
17 //! 3. **Model pick** — after a key validates, choose a default model from
18 //! the provider catalog (provider default pre-selected).
19 //! 4. **Confirm** — summary of provider + masked key + model. Enter emits
20 //! [`ViewEvent::ProviderPickerSetupConfirmed`], which the UI handler
21 //! persists (comment-preserving) before switching.
22 //! 5. **Custom form** — a named OpenAI-compatible endpoint form. Enter submits
23 //! [`ViewEvent::ProviderPickerCustomProviderSubmitted`], which persists a
24 //! `[providers.<name>]` table without storing raw secrets.
25 //!
26 //! Pressing Esc backs out one stage at a time; from the list it closes the
27 //! modal without changes.
28
29 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
30 use ratatui::{
31 buffer::Buffer,
32 layout::{Constraint, Direction, Layout, Rect},
33 style::{Modifier, Style},
34 text::{Line, Span},
35 widgets::{Block, Borders, Paragraph, Widget, Wrap},
36 };
37
38 use crate::config::{
39 ApiProvider, Config, base_url_uses_local_host, has_api_key_for, provider_is_configured,
40 };
41 use crate::core::ops::ProviderRuntimeStatus;
42 use crate::localization::{Locale, MessageId, tr};
43 use crate::model_profile::{
44 SupportState, resolved_capability_profile, resolved_capability_profile_for_route,
45 };
46 use crate::models_dev_live::{self, ModelsDevFreshness};
47 use crate::palette;
48 use crate::provider_lake::{catalog_model_count_for_provider, catalog_offering_for_model};
49 use crate::provider_readiness::{
50 CredentialState, ProviderReadinessSnapshot, ProviderRouteIdentity, ResolvedProviderReadiness,
51 credential_state_for_provider, route_identity_for_model,
52 };
53 use crate::tui::app::ReasoningEffort;
54 use crate::tui::menu_style;
55 use crate::tui::views::{
56 ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
57 centered_modal_area, render_modal_footer, render_modal_surface,
58 };
59 use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot};
60 use codewhale_config::provider::WireFormat;
61 use codewhale_config::route::{PricingSku, RequestProtocol};
62 use serde_json::Value;
63 use std::borrow::Cow;
64 use std::sync::OnceLock;
65
66 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
67 enum Stage {
68 List,
69 /// Explicit xAI acquisition choice. xAI supports both an API key and the
70 /// Codewhale-owned device OAuth flow; neither path may impersonate the other.
71 XaiAuthChoice,
72 KeyEntry,
73 /// Explicit disabled/read-only/managed external-credential policy choice.
74 ExternalConsentChoice,
75 /// Full owner/path/side-effect disclosure before a read grant is saved.
76 ExternalConsentConfirm,
77 /// Default model pick after a key has been live-validated (#3875).
78 ModelPick,
79 /// Kimi Code membership plan selection for the exact `api.kimi.com` route.
80 PlanTier,
81 /// StepFun pay-as-you-go vs Step Plan endpoint choice, asked before key
82 /// entry so the selected route is the one that gets live-validated (#4526).
83 StepfunBillingRoute,
84 /// Confirmation summary before any secret or model is persisted (#3875).
85 Confirm,
86 CustomForm,
87 }
88
89 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
90 enum ExternalConsentChoice {
91 Disabled,
92 ReadOnly,
93 ManagedUnavailable,
94 }
95
96 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
97 enum XaiAuthChoice {
98 ApiKey,
99 DeviceOAuth,
100 }
101
102 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
103 enum KimiCodePlanTier {
104 Safe262k,
105 OneMillion,
106 }
107
108 /// StepFun's two billing tracks. They are separate endpoints, not separate
109 /// keys, so the setup wizard has to pick one before a key can be validated.
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 enum StepfunBillingRoute {
112 PayAsYouGo,
113 StepPlan,
114 }
115
116 impl StepfunBillingRoute {
117 fn base_url(self) -> &'static str {
118 match self {
119 Self::PayAsYouGo => crate::config::DEFAULT_STEPFUN_BASE_URL,
120 Self::StepPlan => crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL,
121 }
122 }
123 }
124
125 /// Whether the StepFun billing-route choice applies to `base_url`.
126 ///
127 /// Only the two endpoints Codewhale can classify are offered. A hand-edited
128 /// endpoint (regional proxy, gateway, anything unrecognized) is a deliberate
129 /// user choice, so the stage is skipped rather than silently rewriting it.
130 fn stepfun_route_is_selectable(provider: ApiProvider, base_url: &str) -> bool {
131 provider == ApiProvider::Stepfun
132 && matches!(
133 crate::pricing::billing_surface_for_route(provider, Some(base_url)),
134 Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE)
135 | Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE)
136 )
137 }
138
139 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
140 enum CustomProviderField {
141 Name,
142 BaseUrl,
143 Model,
144 ApiKeyEnv,
145 }
146
147 /// Which subset of `rows` the list stage shows (#3830). `Configured` is the
148 /// default; `A` toggles to `Catalog` to add a new provider or look at one
149 /// that hasn't been set up yet.
150 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
151 enum ProviderListView {
152 Configured,
153 Catalog,
154 }
155
156 pub struct ProviderPickerView {
157 rows: Vec<ProviderDashboardRow>,
158 selected_idx: usize,
159 stage: Stage,
160 view: ProviderListView,
161 setup_mode: bool,
162 query: String,
163 api_key_input: String,
164 /// An error surfaced after a failed key verification, shown inline
165 /// in the key-entry stage. Cleared when the user edits the input.
166 key_entry_error: Option<String>,
167 locale: Locale,
168 xai_auth_choice: XaiAuthChoice,
169 external_consent_choice: ExternalConsentChoice,
170 /// Validated key held only in memory until the confirm stage persists it.
171 pending_api_key: Option<String>,
172 /// Catalog models offered during the model-pick stage.
173 model_options: Vec<String>,
174 model_selected_idx: usize,
175 /// Model chosen on the model-pick stage (and shown on confirm).
176 selected_model: Option<String>,
177 selected_context_window: Option<u32>,
178 kimi_code_plan_tier: KimiCodePlanTier,
179 stepfun_billing_route: StepfunBillingRoute,
180 /// Endpoint chosen in the setup wizard, carried unpersisted through key
181 /// validation and only written on confirm (#4526).
182 pending_base_url: Option<String>,
183 custom_provider_field: CustomProviderField,
184 custom_provider_id: String,
185 custom_provider_base_url: String,
186 custom_provider_model: String,
187 custom_provider_api_key_env: String,
188 }
189
190 #[derive(Debug, Clone, PartialEq, Eq)]
191 pub struct ProviderDashboardRow {
192 pub provider: ApiProvider,
193 pub provider_id: String,
194 pub display_name: String,
195 pub kind: String,
196 pub base_url: String,
197 pub auth_status: ProviderAuthStatus,
198 pub catalog_status: ProviderCatalogStatus,
199 pub supported_protocols: Vec<String>,
200 pub available_model_count: usize,
201 pub default_route: ProviderDefaultRoute,
202 pub request_concurrency: ProviderRequestConcurrencySummary,
203 pub usage_meter: String,
204 pub reasoning: ProviderReasoningSummary,
205 pub capabilities: ProviderCapabilityBadges,
206 pub model_origin: ProviderModelOrigin,
207 pub(crate) readiness: ResolvedProviderReadiness,
208 pub maturity: ProviderMaturity,
209 pub messages: Vec<String>,
210 external_credential_status: Option<codewhale_config::ExternalCredentialConsentStatus>,
211 pub is_active: bool,
212 has_key: bool,
213 credential_state: CredentialState,
214 route_identity: ProviderRouteIdentity,
215 route_ok: bool,
216 /// Whether this provider should appear in the default `/provider`
217 /// manager view (#3830) without the user explicitly browsing the full
218 /// catalog: the active provider, one with working credentials/OAuth, a
219 /// custom provider entry, or any provider with a non-default
220 /// `[providers.<name>]` table entry. A self-hosted provider type
221 /// (Ollama/Sglang/Vllm) does *not* auto-qualify just because its auth is
222 /// optional — that would clutter the default view with every untouched
223 /// local-provider slot.
224 pub is_configured: bool,
225 }
226
227 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
228 pub enum ProviderAuthStatus {
229 Configured,
230 Missing,
231 NoAuth,
232 Optional,
233 OAuthReady,
234 OAuthConsented,
235 OAuthMissing,
236 ImportedTokenUnavailable,
237 Local,
238 Legacy,
239 }
240
241 #[derive(Debug, Clone, PartialEq, Eq)]
242 pub enum ProviderCatalogStatus {
243 Bundled,
244 DefaultOnly,
245 Legacy,
246 }
247
248 #[derive(Debug, Clone, PartialEq, Eq)]
249 pub struct ProviderDefaultRoute {
250 pub logical_model: String,
251 pub wire_model: String,
252 }
253
254 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
255 pub struct ProviderRequestConcurrencySummary {
256 pub limit: Option<usize>,
257 pub active: Option<usize>,
258 }
259
260 /// How battle-tested a provider integration is, independent of whether the
261 /// user has credentials configured (which `ProviderReadiness` already tracks).
262 /// Kept intentionally minimal — the only two honest states today are an
263 /// experimental integration and a supported one (#2984).
264 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
265 pub enum ProviderMaturity {
266 Experimental,
267 Supported,
268 }
269
270 impl ProviderMaturity {
271 /// Maturity is seeded from a small table keyed by provider. Only the
272 /// OpenAI Codex bridge is experimental today; everything else is supported.
273 fn for_provider(provider: ApiProvider) -> Self {
274 match provider {
275 ApiProvider::OpenaiCodex => Self::Experimental,
276 _ => Self::Supported,
277 }
278 }
279
280 /// Compact tag for the picker hint. Returns `None` when the integration is
281 /// supported so the common case stays noise-free (#2984).
282 fn tag(self) -> Option<&'static str> {
283 match self {
284 Self::Experimental => Some("experimental"),
285 Self::Supported => None,
286 }
287 }
288 }
289
290 /// Where the row's current model came from, so the dashboard can distinguish a
291 /// provider default from a saved override or a custom pass-through id (#3083).
292 /// Live-catalog/static origins are not yet distinguishable here; they arrive
293 /// with the #3385 live-fetch layer.
294 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
295 pub enum ProviderModelOrigin {
296 Default,
297 Saved,
298 Custom,
299 }
300
301 impl ProviderModelOrigin {
302 fn for_provider(provider: ApiProvider, has_saved_model: bool) -> Self {
303 if has_saved_model {
304 Self::Saved
305 } else if provider == ApiProvider::Custom {
306 Self::Custom
307 } else {
308 Self::Default
309 }
310 }
311
312 fn label(self) -> &'static str {
313 match self {
314 Self::Default => "default",
315 Self::Saved => "saved",
316 Self::Custom => "custom",
317 }
318 }
319 }
320
321 /// Capability + metadata badges projected from the resolved capability profile
322 /// (#3083). Tri-state so "unknown" stays distinct from "unsupported"; metadata
323 /// is `None` when not resolvable. Reasoning is tracked separately in
324 /// [`ProviderReasoningSummary`].
325 #[derive(Debug, Clone, PartialEq, Eq)]
326 pub struct ProviderCapabilityBadges {
327 pub context_window: Option<u32>,
328 /// Source receipt for the route-effective context-window badge.
329 pub context_window_source: Option<String>,
330 pub max_output: Option<u32>,
331 pub tools: SupportState,
332 pub structured: SupportState,
333 pub streaming: SupportState,
334 pub cache: SupportState,
335 pub vision: SupportState,
336 }
337
338 impl ProviderCapabilityBadges {
339 fn for_route(provider: ApiProvider, wire_model: &str) -> Self {
340 let cap = catalog_offering_for_model(provider, wire_model).map_or_else(
341 || resolved_capability_profile(provider, wire_model),
342 |offering| {
343 let route_offering = offering.to_offering();
344 resolved_capability_profile_for_route(
345 provider,
346 wire_model,
347 route_offering.capabilities,
348 route_offering.limits,
349 )
350 },
351 );
352 Self {
353 context_window: cap.context_window,
354 context_window_source: None,
355 max_output: cap.max_output,
356 tools: cap.native_tool_calls,
357 structured: cap.structured_output,
358 streaming: cap.streaming,
359 cache: cap.prompt_caching,
360 vision: cap.image_input,
361 }
362 }
363
364 fn unknown() -> Self {
365 Self {
366 context_window: None,
367 context_window_source: None,
368 max_output: None,
369 tools: SupportState::Unknown,
370 structured: SupportState::Unknown,
371 streaming: SupportState::Unknown,
372 cache: SupportState::Unknown,
373 vision: SupportState::Unknown,
374 }
375 }
376
377 /// Compact, never-fabricating badge cluster. Metadata and each capability
378 /// render `?` when unknown rather than being silently dropped.
379 fn label(&self) -> String {
380 format!(
381 "ctx:{}({}) out:{} tools:{} json:{} stream:{} cache:{} vision:{}",
382 humanize_token_count(self.context_window),
383 self.context_window_source.as_deref().unwrap_or("?"),
384 humanize_token_count(self.max_output),
385 support_glyph(self.tools),
386 support_glyph(self.structured),
387 support_glyph(self.streaming),
388 support_glyph(self.cache),
389 support_glyph(self.vision),
390 )
391 }
392 }
393
394 fn support_glyph(state: SupportState) -> &'static str {
395 match state {
396 SupportState::Supported => "y",
397 SupportState::Unsupported => "n",
398 SupportState::Unknown => "?",
399 }
400 }
401
402 fn humanize_token_count(value: Option<u32>) -> String {
403 match value {
404 None => "?".to_string(),
405 Some(v) if v >= 1_000_000 && v % 1_000_000 == 0 => format!("{}M", v / 1_000_000),
406 Some(v) if v >= 1_000_000 => format!("{:.1}M", f64::from(v) / 1_000_000.0),
407 Some(v) if v >= 1_000 => format!("{}K", v / 1_000),
408 Some(v) => v.to_string(),
409 }
410 }
411
412 #[derive(Debug, Clone, PartialEq, Eq)]
413 pub struct ProviderReasoningSummary {
414 pub support: ProviderReasoningSupport,
415 pub controls: Vec<String>,
416 pub stream_visibility: ProviderReasoningStreamVisibility,
417 pub selected_control: Option<String>,
418 }
419
420 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
421 pub enum ProviderReasoningSupport {
422 Supported,
423 Unsupported,
424 Unknown,
425 }
426
427 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
428 pub enum ProviderReasoningStreamVisibility {
429 StructuredThinking,
430 InlineTags,
431 SummaryOnly,
432 NotExposed,
433 Unknown,
434 }
435
436 impl ProviderDashboardRow {
437 #[cfg(test)]
438 fn from_config(provider: ApiProvider, active: ApiProvider, config: &Config) -> Self {
439 Self::from_config_with_runtime_status(provider, active, config, None)
440 }
441
442 fn from_config_with_runtime_status(
443 provider: ApiProvider,
444 active: ApiProvider,
445 config: &Config,
446 runtime_status: Option<&ProviderRuntimeStatus>,
447 ) -> Self {
448 Self::from_config_with_provider_id(
449 provider,
450 active,
451 config,
452 None,
453 config.provider.as_deref(),
454 runtime_status,
455 )
456 }
457
458 fn from_custom_config_with_runtime_status(
459 provider_id: &str,
460 active: ApiProvider,
461 config: &Config,
462 runtime_status: Option<&ProviderRuntimeStatus>,
463 ) -> Self {
464 let mut scoped = config.clone();
465 scoped.provider = Some(provider_id.to_string());
466 Self::from_config_with_provider_id(
467 ApiProvider::Custom,
468 active,
469 &scoped,
470 Some(provider_id),
471 config.provider.as_deref(),
472 runtime_status,
473 )
474 }
475
476 fn from_config_with_provider_id(
477 provider: ApiProvider,
478 active: ApiProvider,
479 config: &Config,
480 provider_id_override: Option<&str>,
481 active_provider_id: Option<&str>,
482 runtime_status: Option<&ProviderRuntimeStatus>,
483 ) -> Self {
484 let configured = config.provider_config_for(provider);
485 let configured_base_url = configured
486 .and_then(|entry| entry.base_url.as_deref())
487 .map(str::trim)
488 .filter(|value| !value.is_empty())
489 .map(str::to_string);
490 let uses_kimi_imported_token = provider == ApiProvider::Moonshot
491 && configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token);
492 let configured_base_url = configured_base_url.or_else(|| {
493 uses_kimi_imported_token.then(|| crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string())
494 });
495 let explicitly_configured_model = configured
496 .and_then(|entry| entry.model.as_deref())
497 .map(str::trim)
498 .filter(|value| !value.is_empty())
499 .map(str::to_string);
500 let has_configured_model = explicitly_configured_model.is_some();
501 let configured_model = explicitly_configured_model.or_else(|| {
502 uses_kimi_imported_token.then(|| crate::config::DEFAULT_KIMI_CODE_MODEL.to_string())
503 });
504 let model_origin = ProviderModelOrigin::for_provider(provider, has_configured_model);
505 let has_key = if provider == ApiProvider::Custom {
506 custom_provider_has_auth(configured)
507 } else {
508 has_api_key_for(config, provider)
509 };
510 let credential_state = credential_state_for_provider(config, provider);
511 let auth_mode = config.auth_mode_for_provider(provider);
512 let no_auth = crate::config::auth_mode_disables_api_key(auth_mode.as_deref());
513 let api_key_required = crate::config::auth_mode_requires_api_key(auth_mode.as_deref());
514 let official_endpoint = !config.provider_uses_custom_endpoint(provider);
515 let xai_oauth_ready = provider == ApiProvider::Xai
516 && official_endpoint
517 && crate::xai_oauth::credentials_valid(config);
518 let auth_status = if credential_state == CredentialState::ExternalConsent {
519 ProviderAuthStatus::OAuthConsented
520 } else {
521 auth_status_for(
522 provider,
523 has_key,
524 configured,
525 no_auth,
526 api_key_required,
527 official_endpoint,
528 xai_oauth_ready,
529 )
530 };
531 let usage_meter = if matches!(auth_status, ProviderAuthStatus::ImportedTokenUnavailable) {
532 "usage: Kimi API key required".to_string()
533 } else {
534 usage_meter_for(provider)
535 };
536 let provider_id = provider_id_override
537 .map(str::to_string)
538 .unwrap_or_else(|| provider.as_str().to_string());
539 let display_name = provider_id_override
540 .map(|id| format!("{id} (custom)"))
541 .unwrap_or_else(|| provider.display_name().to_string());
542 let is_active = if provider == ApiProvider::Custom {
543 active == ApiProvider::Custom
544 && match provider_id_override {
545 Some(id) => active_provider_id == Some(id),
546 None => true,
547 }
548 } else {
549 provider == active
550 };
551 let request_concurrency =
552 ProviderRequestConcurrencySummary::for_row(provider, config, runtime_status, is_active);
553
554 let compatibility_kind = (provider == ApiProvider::DeepseekCN)
555 .then_some(codewhale_config::ProviderKind::Deepseek);
556 let Some(kind) = provider.kind().or(compatibility_kind) else {
557 return Self {
558 provider,
559 provider_id,
560 display_name,
561 kind: "legacy".to_string(),
562 base_url: configured_base_url
563 .unwrap_or_else(|| provider.default_base_url().to_string()),
564 auth_status: ProviderAuthStatus::Legacy,
565 catalog_status: ProviderCatalogStatus::Legacy,
566 supported_protocols: vec![protocol_label(WireFormat::ChatCompletions).to_string()],
567 available_model_count: 0,
568 default_route: ProviderDefaultRoute {
569 logical_model: configured_model
570 .clone()
571 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
572 wire_model: "legacy alias".to_string(),
573 },
574 request_concurrency,
575 usage_meter,
576 reasoning: ProviderReasoningSummary::unknown(provider, config),
577 capabilities: ProviderCapabilityBadges::unknown(),
578 model_origin,
579 readiness: ResolvedProviderReadiness::Legacy,
580 maturity: ProviderMaturity::for_provider(provider),
581 messages: vec![
582 "legacy DeepSeek China alias; routing maps through DeepSeek compatibility"
583 .to_string(),
584 ],
585 external_credential_status: None,
586 is_active,
587 has_key,
588 credential_state: CredentialState::Legacy,
589 route_identity: route_identity_for_model(
590 config,
591 provider,
592 configured_model.as_deref().unwrap_or("deepseek-v4-pro"),
593 ),
594 route_ok: true,
595 is_configured: provider_is_configured(
596 provider,
597 is_active,
598 has_key,
599 configured,
600 provider == ApiProvider::Custom && provider_id_override.is_some(),
601 ),
602 };
603 };
604
605 let available_model_count = catalog_model_count_for_provider(provider);
606 let catalog_status = if available_model_count == 0 {
607 ProviderCatalogStatus::DefaultOnly
608 } else {
609 ProviderCatalogStatus::Bundled
610 };
611 let mut messages = Vec::new();
612 // Use the same route-effective resolver as the active runtime. In
613 // particular, Kimi Code's bare K3 model has a conservative 262K
614 // membership-plan baseline (or an explicit configured override), not
615 // the generic catalog's unknown-model fallback.
616 let route = crate::route_runtime::resolve_route_candidate_with_context_metadata(
617 provider,
618 configured_model.as_deref(),
619 None,
620 // The legacy CN alias shares DeepSeek's strict model contract.
621 // Passing its endpoint as a generic override would classify the
622 // route as custom and accidentally accept foreign model ids.
623 (provider != ApiProvider::DeepseekCN)
624 .then(|| configured_base_url.clone())
625 .flatten(),
626 config.context_window_for_provider_config(provider),
627 None,
628 );
629 let (
630 base_url,
631 supported_protocols,
632 default_route,
633 resolved_pricing,
634 route_ok,
635 route_context_window,
636 route_context_window_source,
637 ) = match route {
638 Ok(resolution) => {
639 let candidate = resolution.candidate;
640 if !candidate.validation().messages.is_empty() {
641 messages.extend(candidate.validation().messages.clone());
642 }
643 (
644 if provider == ApiProvider::DeepseekCN {
645 configured_base_url
646 .clone()
647 .unwrap_or_else(|| provider.default_base_url().to_string())
648 } else {
649 candidate.endpoint().base_url.clone()
650 },
651 vec![protocol_label(candidate.protocol()).to_string()],
652 ProviderDefaultRoute {
653 logical_model: candidate.logical_model().raw().to_string(),
654 wire_model: candidate.wire_model_id().as_str().to_string(),
655 },
656 pricing_label(provider, candidate.pricing()),
657 candidate.validation().ok,
658 Some(resolution.context_window.tokens),
659 Some(resolution.context_window.source.label().to_string()),
660 )
661 }
662 Err(error) => {
663 messages.push(format!("route validation failed: {error}"));
664 (
665 configured_base_url.unwrap_or_else(|| provider.default_base_url().to_string()),
666 vec![
667 provider
668 .metadata()
669 .and_then(|metadata| metadata.wire_policy().fixed())
670 .map(|protocol| protocol_label(protocol).to_string())
671 .unwrap_or_else(|| "model-aware".to_string()),
672 ],
673 ProviderDefaultRoute {
674 logical_model: configured_model.unwrap_or_else(|| "invalid".to_string()),
675 wire_model: "unresolved".to_string(),
676 },
677 usage_meter.clone(),
678 false,
679 None,
680 None,
681 )
682 }
683 };
684 let resolved_pricing =
685 if matches!(auth_status, ProviderAuthStatus::ImportedTokenUnavailable) {
686 usage_meter
687 } else {
688 resolved_pricing
689 };
690
691 if matches!(
692 auth_status,
693 ProviderAuthStatus::Missing
694 | ProviderAuthStatus::OAuthMissing
695 | ProviderAuthStatus::ImportedTokenUnavailable
696 ) {
697 messages.push(missing_auth_message(provider, configured, &provider_id));
698 }
699 if catalog_status == ProviderCatalogStatus::DefaultOnly {
700 messages.push("catalog snapshot missing; using provider default".to_string());
701 }
702
703 let route_identity =
704 route_identity_for_model(config, provider, &default_route.logical_model);
705 let readiness = readiness_for(
706 &route_identity,
707 credential_state,
708 route_ok,
709 &ProviderReadinessSnapshot::default(),
710 );
711 let reasoning =
712 ProviderReasoningSummary::for_route(provider, &base_url, &default_route, config);
713 let mut capabilities =
714 ProviderCapabilityBadges::for_route(provider, &default_route.wire_model);
715 if let Some(context_window) = route_context_window {
716 capabilities.context_window = Some(context_window);
717 }
718 capabilities.context_window_source = route_context_window_source;
719 let external_credential_status = config.external_credential_consent_status(provider);
720
721 Self {
722 provider,
723 provider_id,
724 display_name,
725 kind: configured
726 .and_then(|entry| entry.kind.as_deref())
727 .filter(|value| !value.trim().is_empty())
728 .map(str::to_string)
729 .unwrap_or_else(|| format!("{kind:?}")),
730 base_url,
731 auth_status,
732 catalog_status,
733 supported_protocols,
734 available_model_count,
735 default_route,
736 request_concurrency,
737 usage_meter: resolved_pricing,
738 reasoning,
739 capabilities,
740 model_origin,
741 readiness,
742 maturity: ProviderMaturity::for_provider(provider),
743 messages,
744 external_credential_status,
745 is_active,
746 has_key,
747 credential_state,
748 route_identity,
749 route_ok,
750 is_configured: provider_is_configured(
751 provider,
752 is_active,
753 has_key,
754 configured,
755 provider == ApiProvider::Custom && provider_id_override.is_some(),
756 ),
757 }
758 }
759
760 fn list_row_hint(&self, view: ProviderListView) -> String {
761 match view {
762 ProviderListView::Configured => {
763 format!("{} | {}", self.readiness.label(), self.auth_status.label())
764 }
765 ProviderListView::Catalog => self.compact_hint(),
766 }
767 }
768
769 fn compact_hint(&self) -> String {
770 // Self-hosted providers carry a local/private posture; surface it next
771 // to the base URL so the row reads correctly without a key (#3083).
772 let self_hosted = if self.provider.is_self_hosted()
773 || matches!(
774 self.auth_status,
775 ProviderAuthStatus::Local | ProviderAuthStatus::Optional
776 ) {
777 " (self-hosted)"
778 } else {
779 ""
780 };
781 let request_concurrency = self
782 .request_concurrency
783 .label()
784 .map(|label| format!(" | {label}"))
785 .unwrap_or_default();
786 format!(
787 "{} | {} | {} | {} | base:{}{} | route:{}{} origin:{} | {} | {}{} | catalog:{}{}",
788 self.readiness.label(),
789 self.auth_status.label(),
790 self.usage_meter,
791 self.supported_protocols.join("+"),
792 compact_base_url(&self.base_url),
793 self_hosted,
794 self.default_route.logical_model,
795 route_wire_suffix(&self.default_route),
796 self.model_origin.label(),
797 self.capabilities.label(),
798 self.reasoning.label(),
799 request_concurrency,
800 self.catalog_label(),
801 // Only experimental integrations add a tag; supported ones stay
802 // noise-free (#2984).
803 self.maturity
804 .tag()
805 .map(|tag| format!(" | {tag}"))
806 .unwrap_or_default(),
807 )
808 }
809
810 fn catalog_label(&self) -> String {
811 match self.catalog_status {
812 ProviderCatalogStatus::Bundled => format!("{} bundled", self.available_model_count),
813 ProviderCatalogStatus::DefaultOnly => "default-only".to_string(),
814 ProviderCatalogStatus::Legacy => "legacy".to_string(),
815 }
816 }
817
818 /// Cross-field search (#3830 P1, #4141): match a query against the provider
819 /// name (display name, provider id, kind, provider key), the base URL, and
820 /// the default route's display model name and wire model id. Matching the
821 /// route means a model name or wire id surfaces the provider that serves it,
822 /// keeping this picker consistent with the model picker's cross-field search
823 /// (`model_row_matches_query`).
824 fn matches_query(&self, query: &str) -> bool {
825 let query = query.trim().to_ascii_lowercase();
826 if query.is_empty() {
827 return true;
828 }
829 self.display_name.to_ascii_lowercase().contains(&query)
830 || self.provider_id.to_ascii_lowercase().contains(&query)
831 || self.kind.to_ascii_lowercase().contains(&query)
832 || self.base_url.to_ascii_lowercase().contains(&query)
833 || self.provider.as_str().to_ascii_lowercase().contains(&query)
834 || self
835 .default_route
836 .logical_model
837 .to_ascii_lowercase()
838 .contains(&query)
839 || self
840 .default_route
841 .wire_model
842 .to_ascii_lowercase()
843 .contains(&query)
844 }
845 }
846
847 impl ProviderRequestConcurrencySummary {
848 fn for_row(
849 provider: ApiProvider,
850 config: &Config,
851 runtime_status: Option<&ProviderRuntimeStatus>,
852 is_active: bool,
853 ) -> Self {
854 let mut summary = Self {
855 limit: config.provider_max_concurrency(provider),
856 active: None,
857 };
858 if is_active
859 && let Some(status) = runtime_status
860 && status.provider == provider
861 {
862 summary.limit = status.request_concurrency_limit;
863 summary.active = Some(status.active_provider_requests);
864 }
865 summary
866 }
867
868 fn label(self) -> Option<String> {
869 match (self.limit, self.active) {
870 (Some(limit), Some(active)) => Some(format!("req:{active}/{limit}")),
871 (Some(limit), None) => Some(format!("req:cap {limit}")),
872 (None, Some(active)) if active > 0 => Some(format!("req:{active}/uncapped")),
873 _ => None,
874 }
875 }
876 }
877
878 impl ProviderReasoningSummary {
879 fn for_route(
880 provider: ApiProvider,
881 base_url: &str,
882 route: &ProviderDefaultRoute,
883 config: &Config,
884 ) -> Self {
885 if provider == ApiProvider::OpenaiCodex {
886 return Self {
887 support: ProviderReasoningSupport::Supported,
888 controls: codex_reasoning_controls(),
889 stream_visibility: ProviderReasoningStreamVisibility::StructuredThinking,
890 selected_control: selected_reasoning_control(provider, config),
891 };
892 }
893
894 // The bare `k3` ID is deliberately not listed as a generic Moonshot
895 // model. Kimi Code owns this reasoning contract only at its exact
896 // membership-plan endpoint, so surface the capability before key
897 // entry without attributing it to neighboring Moonshot routes.
898 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, &route.wire_model) {
899 return Self {
900 support: ProviderReasoningSupport::Supported,
901 controls: vec!["low".to_string(), "high".to_string(), "max".to_string()],
902 stream_visibility: configured_or_default_stream_visibility(
903 provider,
904 config,
905 ProviderReasoningSupport::Supported,
906 ),
907 selected_control: selected_reasoning_control(provider, config),
908 };
909 }
910
911 if let Some(offering) = reasoning_catalog_offering(provider, route) {
912 let support = match offering.reasoning {
913 Some(true) => ProviderReasoningSupport::Supported,
914 Some(false) => ProviderReasoningSupport::Unsupported,
915 None => ProviderReasoningSupport::Unknown,
916 };
917 let controls = reasoning_controls_from_options(&offering.reasoning_options);
918 return Self {
919 support,
920 controls,
921 stream_visibility: configured_or_default_stream_visibility(
922 provider, config, support,
923 ),
924 selected_control: selected_reasoning_control(provider, config),
925 };
926 }
927
928 Self::unknown(provider, config)
929 }
930
931 fn unknown(provider: ApiProvider, config: &Config) -> Self {
932 Self {
933 support: ProviderReasoningSupport::Unknown,
934 controls: Vec::new(),
935 stream_visibility: configured_or_default_stream_visibility(
936 provider,
937 config,
938 ProviderReasoningSupport::Unknown,
939 ),
940 selected_control: selected_reasoning_control(provider, config),
941 }
942 }
943
944 fn label(&self) -> String {
945 let support = match self.support {
946 ProviderReasoningSupport::Supported if !self.controls.is_empty() => {
947 format!("reasoning:{}", self.controls.join("/"))
948 }
949 ProviderReasoningSupport::Supported => "reasoning:yes".to_string(),
950 ProviderReasoningSupport::Unsupported => "reasoning:no".to_string(),
951 ProviderReasoningSupport::Unknown => "reasoning:unknown".to_string(),
952 };
953 let mut parts = vec![
954 support,
955 format!("stream:{}", self.stream_visibility.label()),
956 ];
957 if let Some(selected) = &self.selected_control {
958 parts.push(format!("ctrl:{selected}"));
959 }
960 parts.join(" ")
961 }
962 }
963
964 impl ProviderReasoningStreamVisibility {
965 fn label(self) -> &'static str {
966 match self {
967 Self::StructuredThinking => "structured",
968 Self::InlineTags => "inline-tags",
969 Self::SummaryOnly => "summary-only",
970 Self::NotExposed => "not-exposed",
971 Self::Unknown => "unknown",
972 }
973 }
974 }
975
976 impl ProviderAuthStatus {
977 fn label(self) -> &'static str {
978 match self {
979 Self::Configured => "key:configured",
980 Self::Missing => "key:not-set",
981 Self::NoAuth => "auth:none",
982 Self::Optional => "key:optional",
983 Self::OAuthReady => "auth:oauth-ready",
984 Self::OAuthConsented => "auth:oauth-consented-select-to-check",
985 Self::OAuthMissing => "auth:oauth-missing",
986 Self::ImportedTokenUnavailable => "auth:imported-token-unavailable",
987 Self::Local => "local",
988 Self::Legacy => "legacy",
989 }
990 }
991 }
992
993 /// Compact Models.dev freshness chip for the provider picker chrome (#4139).
994 fn catalog_freshness_title_suffix() -> &'static str {
995 match models_dev_live::status().freshness {
996 ModelsDevFreshness::Stale => " · stale",
997 ModelsDevFreshness::Failed => " · cache failed",
998 ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "",
999 }
1000 }
1001
1002 fn reasoning_catalog_offering(
1003 provider: ApiProvider,
1004 route: &ProviderDefaultRoute,
1005 ) -> Option<&'static CatalogOffering> {
1006 let provider_id = provider.kind()?.as_str();
1007 bundled_reasoning_catalog()
1008 .offerings
1009 .iter()
1010 .find(|offering| {
1011 offering.provider == provider_id
1012 && offering
1013 .wire_model_id
1014 .eq_ignore_ascii_case(&route.wire_model)
1015 })
1016 }
1017
1018 fn bundled_reasoning_catalog() -> &'static CatalogSnapshot {
1019 static CATALOG: OnceLock<CatalogSnapshot> = OnceLock::new();
1020 CATALOG.get_or_init(|| CatalogSnapshot {
1021 // Source reasoning descriptors from the single bundled Models.dev
1022 // snapshot (the same data #3385's catalog layer uses) rather than a
1023 // hand-maintained per-row seed, so provider reasoning rows (GLM-5.2,
1024 // etc.) cannot drift from the catalog and every bundled provider with
1025 // reasoning facts is covered, not just GLM.
1026 offerings: codewhale_config::catalog::bundled_catalog_offerings(),
1027 })
1028 }
1029
1030 fn codex_reasoning_controls() -> Vec<String> {
1031 [
1032 ReasoningEffort::Low,
1033 ReasoningEffort::Medium,
1034 ReasoningEffort::High,
1035 ReasoningEffort::Max,
1036 ]
1037 .iter()
1038 .map(|effort| {
1039 effort
1040 .display_label_for_provider(ApiProvider::OpenaiCodex)
1041 .to_string()
1042 })
1043 .collect()
1044 }
1045
1046 fn reasoning_controls_from_options(options: &[Value]) -> Vec<String> {
1047 let mut controls = Vec::new();
1048 for option in options {
1049 collect_reasoning_controls(option, &mut controls);
1050 }
1051 controls
1052 }
1053
1054 fn collect_reasoning_controls(value: &Value, controls: &mut Vec<String>) {
1055 match value {
1056 Value::String(text) => push_reasoning_control(controls, text),
1057 Value::Array(items) => {
1058 for item in items {
1059 collect_reasoning_controls(item, controls);
1060 }
1061 }
1062 Value::Object(map) => {
1063 if let Some(values) = map.get("values") {
1064 collect_reasoning_controls(values, controls);
1065 }
1066 }
1067 _ => {}
1068 }
1069 }
1070
1071 fn push_reasoning_control(controls: &mut Vec<String>, value: &str) {
1072 let normalized = value.trim();
1073 if normalized.is_empty() || controls.iter().any(|item| item == normalized) {
1074 return;
1075 }
1076 controls.push(normalized.to_string());
1077 }
1078
1079 fn selected_reasoning_control(provider: ApiProvider, config: &Config) -> Option<String> {
1080 let effort = ReasoningEffort::from_setting_for_provider(config.reasoning_effort()?, provider);
1081 Some(effort.display_label_for_provider(provider).to_string())
1082 }
1083
1084 fn configured_or_default_stream_visibility(
1085 provider: ApiProvider,
1086 config: &Config,
1087 support: ProviderReasoningSupport,
1088 ) -> ProviderReasoningStreamVisibility {
1089 if let Some(configured) = config
1090 .provider_config_for(provider)
1091 .and_then(|entry| entry.reasoning_stream_style.as_deref())
1092 && let Some(visibility) = parse_reasoning_stream_visibility(configured)
1093 {
1094 return visibility;
1095 }
1096
1097 match support {
1098 ProviderReasoningSupport::Unsupported => ProviderReasoningStreamVisibility::NotExposed,
1099 ProviderReasoningSupport::Unknown => ProviderReasoningStreamVisibility::Unknown,
1100 ProviderReasoningSupport::Supported => default_reasoning_stream_visibility(provider),
1101 }
1102 }
1103
1104 fn parse_reasoning_stream_visibility(value: &str) -> Option<ProviderReasoningStreamVisibility> {
1105 match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
1106 "separate_field" | "separate" | "field" | "structured" | "structured_thinking" => {
1107 Some(ProviderReasoningStreamVisibility::StructuredThinking)
1108 }
1109 "inline_tags" | "inline" | "think_tags" | "thinking_tags" => {
1110 Some(ProviderReasoningStreamVisibility::InlineTags)
1111 }
1112 "summary" | "summary_only" => Some(ProviderReasoningStreamVisibility::SummaryOnly),
1113 "none" | "text" | "disabled" | "off" | "not_exposed" => {
1114 Some(ProviderReasoningStreamVisibility::NotExposed)
1115 }
1116 _ => None,
1117 }
1118 }
1119
1120 fn default_reasoning_stream_visibility(provider: ApiProvider) -> ProviderReasoningStreamVisibility {
1121 match provider {
1122 ApiProvider::OpenaiCodex
1123 | ApiProvider::Deepseek
1124 | ApiProvider::DeepseekCN
1125 | ApiProvider::NvidiaNim
1126 | ApiProvider::Openrouter
1127 | ApiProvider::XiaomiMimo
1128 | ApiProvider::Novita
1129 | ApiProvider::Fireworks
1130 | ApiProvider::Siliconflow
1131 | ApiProvider::SiliconflowCn
1132 | ApiProvider::Volcengine
1133 | ApiProvider::Arcee
1134 | ApiProvider::Minimax
1135 | ApiProvider::MinimaxAnthropic
1136 | ApiProvider::Sglang
1137 | ApiProvider::Vllm
1138 | ApiProvider::Zai
1139 | ApiProvider::Xai
1140 // Model Studio surfaces reasoning as structured Thinking on both
1141 // dialects: `delta.reasoning_content` on the OpenAI-compatible
1142 // routes, thinking blocks on the Anthropic-compatible routes.
1143 | ApiProvider::ModelstudioTokenPlan
1144 | ApiProvider::ModelstudioTokenPlanAnthropic
1145 | ApiProvider::ModelstudioCodingPlan
1146 | ApiProvider::ModelstudioCodingPlanAnthropic
1147 | ApiProvider::Moonshot => ProviderReasoningStreamVisibility::StructuredThinking,
1148 _ => ProviderReasoningStreamVisibility::Unknown,
1149 }
1150 }
1151
1152 fn auth_status_for(
1153 provider: ApiProvider,
1154 has_key: bool,
1155 configured: Option<&crate::config::ProviderConfig>,
1156 no_auth: bool,
1157 api_key_required: bool,
1158 official_endpoint: bool,
1159 xai_oauth_ready: bool,
1160 ) -> ProviderAuthStatus {
1161 if no_auth {
1162 return ProviderAuthStatus::NoAuth;
1163 }
1164 if provider.is_self_hosted() {
1165 if api_key_required {
1166 return if has_key {
1167 ProviderAuthStatus::Configured
1168 } else {
1169 ProviderAuthStatus::Missing
1170 };
1171 }
1172 if provider == ApiProvider::Ollama {
1173 return ProviderAuthStatus::Local;
1174 }
1175 return if has_explicit_credential(provider, configured) {
1176 ProviderAuthStatus::Configured
1177 } else {
1178 ProviderAuthStatus::Optional
1179 };
1180 }
1181 if provider == ApiProvider::Custom {
1182 return if custom_provider_auth_is_optional(configured) {
1183 ProviderAuthStatus::Optional
1184 } else if has_key {
1185 ProviderAuthStatus::Configured
1186 } else {
1187 ProviderAuthStatus::Missing
1188 };
1189 }
1190 if provider == ApiProvider::Moonshot
1191 && official_endpoint
1192 && configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
1193 {
1194 return ProviderAuthStatus::ImportedTokenUnavailable;
1195 }
1196 if provider == ApiProvider::OpenaiCodex && official_endpoint {
1197 return if has_key {
1198 ProviderAuthStatus::OAuthReady
1199 } else {
1200 ProviderAuthStatus::OAuthMissing
1201 };
1202 }
1203 if provider == ApiProvider::Xai
1204 && official_endpoint
1205 && let Some(status) = xai_oauth_status(configured, xai_oauth_ready)
1206 {
1207 return status;
1208 }
1209 if has_key {
1210 ProviderAuthStatus::Configured
1211 } else {
1212 ProviderAuthStatus::Missing
1213 }
1214 }
1215
1216 fn xai_oauth_status(
1217 configured: Option<&crate::config::ProviderConfig>,
1218 oauth_credentials_present: bool,
1219 ) -> Option<ProviderAuthStatus> {
1220 let oauth_selected = configured
1221 .and_then(|entry| entry.auth_mode.as_deref())
1222 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth);
1223 if !oauth_selected {
1224 return None;
1225 }
1226 Some(if oauth_credentials_present {
1227 ProviderAuthStatus::OAuthReady
1228 } else if has_explicit_credential(ApiProvider::Xai, configured) {
1229 ProviderAuthStatus::Configured
1230 } else {
1231 ProviderAuthStatus::OAuthMissing
1232 })
1233 }
1234
1235 fn has_explicit_credential(
1236 provider: ApiProvider,
1237 configured: Option<&crate::config::ProviderConfig>,
1238 ) -> bool {
1239 provider
1240 .env_vars()
1241 .iter()
1242 .any(|var| std::env::var(var).is_ok_and(|value| !value.trim().is_empty()))
1243 || configured.is_some_and(|entry| {
1244 entry.api_key.as_deref().is_some_and(|value| {
1245 crate::config::classify_config_api_key_value(value)
1246 == crate::config::ConfigApiKeyValueKind::Literal
1247 })
1248 })
1249 }
1250
1251 fn custom_provider_has_auth(configured: Option<&crate::config::ProviderConfig>) -> bool {
1252 if custom_provider_auth_is_optional(configured) {
1253 return true;
1254 }
1255 configured.is_some_and(|entry| {
1256 entry.api_key.as_deref().is_some_and(|value| {
1257 crate::config::classify_config_api_key_value(value)
1258 == crate::config::ConfigApiKeyValueKind::Literal
1259 }) || entry
1260 .api_key_env
1261 .as_deref()
1262 .map(str::trim)
1263 .filter(|name| !name.is_empty())
1264 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
1265 })
1266 }
1267
1268 fn custom_provider_auth_is_optional(configured: Option<&crate::config::ProviderConfig>) -> bool {
1269 configured.is_some_and(|entry| {
1270 entry
1271 .auth_mode
1272 .as_deref()
1273 .is_some_and(|mode| crate::config::auth_mode_disables_api_key(Some(mode)))
1274 || entry
1275 .base_url
1276 .as_deref()
1277 .is_some_and(base_url_uses_local_host)
1278 })
1279 }
1280
1281 fn missing_auth_message(
1282 provider: ApiProvider,
1283 configured: Option<&crate::config::ProviderConfig>,
1284 provider_id: &str,
1285 ) -> String {
1286 if provider == ApiProvider::Moonshot
1287 && configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
1288 {
1289 return "Kimi OAuth is unavailable; configure a Kimi API key".to_string();
1290 }
1291 if provider == ApiProvider::Custom {
1292 if let Some(env_name) = configured
1293 .and_then(|entry| entry.api_key_env.as_deref())
1294 .map(str::trim)
1295 .filter(|name| !name.is_empty())
1296 {
1297 return format!("missing {env_name} for custom provider {provider_id}");
1298 }
1299 return format!("missing custom provider auth for {provider_id}");
1300 }
1301 format!("missing {}", provider.env_vars_label())
1302 }
1303
1304 fn readiness_for(
1305 identity: &ProviderRouteIdentity,
1306 credential: CredentialState,
1307 route_ok: bool,
1308 health: &ProviderReadinessSnapshot,
1309 ) -> ResolvedProviderReadiness {
1310 crate::provider_readiness::resolve_with_identity(identity, credential, route_ok, health)
1311 }
1312
1313 fn usage_meter_for(provider: ApiProvider) -> String {
1314 match provider {
1315 ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => "cost: local".to_string(),
1316 ApiProvider::OpenaiCodex => "usage: Codex OAuth quota".to_string(),
1317 ApiProvider::XiaomiMimo => "cost: token-plan".to_string(),
1318 // OpenCode ships two billing tracks off one account; the rows must not
1319 // both read as generic metering (#4526).
1320 ApiProvider::OpencodeGo => "usage: OpenCode Go subscription".to_string(),
1321 ApiProvider::OpencodeZen => "cost: OpenCode Zen pay-as-you-go".to_string(),
1322 _ => "cost: unknown".to_string(),
1323 }
1324 }
1325
1326 fn pricing_label(provider: ApiProvider, pricing: Option<&PricingSku>) -> String {
1327 // OpenCode Go spends a subscription allowance, not per-token dollars, so a
1328 // catalog token price would misreport it as metered spend.
1329 if provider == ApiProvider::OpencodeGo {
1330 return usage_meter_for(provider);
1331 }
1332 match pricing {
1333 Some(PricingSku::Token {
1334 input_per_mtok,
1335 output_per_mtok,
1336 }) => match (input_per_mtok, output_per_mtok) {
1337 (Some(input), Some(output)) => format!("cost: ${input:.2}/${output:.2} mtok"),
1338 _ => "cost: token".to_string(),
1339 },
1340 Some(PricingSku::SubscriptionQuota { used_pct, .. }) => used_pct.map_or_else(
1341 || "usage: subscription quota".to_string(),
1342 |pct| format!("usage: subscription {pct:.0}%"),
1343 ),
1344 Some(PricingSku::AccountCredits { balance }) => balance.map_or_else(
1345 || "usage: account credits".to_string(),
1346 |balance| format!("usage: ${balance:.2} credits"),
1347 ),
1348 Some(PricingSku::LocalOrNotApplicable) => "cost: local".to_string(),
1349 Some(PricingSku::UnknownOrStale) | None => usage_meter_for(provider),
1350 }
1351 }
1352
1353 fn protocol_label(protocol: RequestProtocol) -> &'static str {
1354 match protocol {
1355 WireFormat::ChatCompletions => "chat",
1356 WireFormat::Responses => "responses",
1357 WireFormat::AnthropicMessages => "anthropic",
1358 }
1359 }
1360
1361 fn route_wire_suffix(route: &ProviderDefaultRoute) -> String {
1362 if route.logical_model == route.wire_model {
1363 String::new()
1364 } else {
1365 format!(" -> {}", route.wire_model)
1366 }
1367 }
1368
1369 /// Strip the scheme and trailing slash, then cap the length so one long base
1370 /// URL can't dominate (and overflow) the provider hint row. Capped values get
1371 /// an ellipsis; short URLs pass through unchanged.
1372 fn compact_base_url(base_url: &str) -> String {
1373 let stripped = base_url
1374 .trim()
1375 .trim_start_matches("https://")
1376 .trim_start_matches("http://")
1377 .trim_end_matches('/');
1378 crate::tui::ui_text::truncate_line_to_width(stripped, 24)
1379 }
1380
1381 /// Resolve the external credential target for a provider that supports
1382 /// read-only external consent. This is the same lower-level fact the
1383 /// provider picker uses to build its consent flow; Fleet setup reuses it
1384 /// for route-scoped activation without switching the parent session.
1385 #[must_use]
1386 pub(crate) fn external_consent_target_for_provider(
1387 provider: ApiProvider,
1388 ) -> Option<(
1389 codewhale_config::ProviderKind,
1390 codewhale_config::ExternalCredentialSource,
1391 std::path::PathBuf,
1392 )> {
1393 let (consent_provider, source, path) = match provider {
1394 ApiProvider::OpenaiCodex => (
1395 codewhale_config::ProviderKind::OpenaiCodex,
1396 codewhale_config::ExternalCredentialSource::CodexCli,
1397 crate::oauth::auth_file_path(),
1398 ),
1399 ApiProvider::Xai => (
1400 codewhale_config::ProviderKind::Xai,
1401 codewhale_config::ExternalCredentialSource::GrokCli,
1402 crate::xai_oauth::auth_file_path(),
1403 ),
1404 _ => return None,
1405 };
1406 let path = codewhale_config::resolve_external_credential_path(path).ok()?;
1407 Some((consent_provider, source, path))
1408 }
1409
1410 impl ProviderPickerView {
1411 #[cfg(test)]
1412 #[must_use]
1413 pub fn new(active: ApiProvider, config: &Config) -> Self {
1414 Self::new_with_runtime_status(active, config, None)
1415 }
1416
1417 #[must_use]
1418 pub fn new_with_runtime_status(
1419 active: ApiProvider,
1420 config: &Config,
1421 runtime_status: Option<ProviderRuntimeStatus>,
1422 ) -> Self {
1423 Self::new_with_runtime_status_and_memory(active, config, runtime_status, None)
1424 }
1425
1426 #[must_use]
1427 pub fn new_with_runtime_status_and_memory(
1428 active: ApiProvider,
1429 config: &Config,
1430 runtime_status: Option<ProviderRuntimeStatus>,
1431 memory: Option<&crate::tui::app::ProviderPickerMemory>,
1432 ) -> Self {
1433 // Build the setup/catalog universe directly from ApiProvider::all so
1434 // first-run and recovery use the same canonical provider surface as
1435 // the runtime, not a historical onboarding shortlist. The active
1436 // provider is highlighted via `selected_idx` below, so it is never
1437 // lost in the list.
1438 let runtime_status = runtime_status.as_ref();
1439 let custom_rows = custom_provider_dashboard_rows(active, config, runtime_status);
1440 // Catalog surface = ProviderKind::ALL (one identity per vendor). Dual
1441 // dialect / plan-variant kinds stay resolvable but are not separate
1442 // rows; plan is mode/base_url and dialect is providers.<id>.wire.
1443 let catalog_active = active.catalog_identity();
1444 let mut rows: Vec<ProviderDashboardRow> = ApiProvider::catalog()
1445 .iter()
1446 .copied()
1447 .filter(|provider| *provider != ApiProvider::Custom || custom_rows.is_empty())
1448 .map(|p| {
1449 ProviderDashboardRow::from_config_with_runtime_status(
1450 p,
1451 catalog_active,
1452 config,
1453 runtime_status,
1454 )
1455 })
1456 .collect();
1457 rows.extend(custom_rows);
1458 rows.sort_by(|a, b| {
1459 a.display_name
1460 .to_ascii_lowercase()
1461 .cmp(&b.display_name.to_ascii_lowercase())
1462 .then_with(|| a.provider_id.cmp(&b.provider_id))
1463 });
1464 let selected_idx = rows
1465 .iter()
1466 .position(|row| row.is_active)
1467 .or_else(|| rows.iter().position(|row| row.provider == active))
1468 .unwrap_or(0);
1469 // Default to the configured-only view (#3830); if nothing is
1470 // configured yet (a fresh install), open straight on the full
1471 // catalog instead of an empty list with no obvious next step.
1472 let view = if rows.iter().any(|row| row.is_configured) {
1473 ProviderListView::Configured
1474 } else {
1475 ProviderListView::Catalog
1476 };
1477 let mut picker = Self {
1478 rows,
1479 selected_idx,
1480 stage: Stage::List,
1481 view,
1482 setup_mode: false,
1483 query: String::new(),
1484 api_key_input: String::new(),
1485 key_entry_error: None,
1486 locale: Locale::En,
1487 xai_auth_choice: XaiAuthChoice::ApiKey,
1488 external_consent_choice: ExternalConsentChoice::Disabled,
1489 pending_api_key: None,
1490 model_options: Vec::new(),
1491 model_selected_idx: 0,
1492 selected_model: None,
1493 selected_context_window: None,
1494 kimi_code_plan_tier: KimiCodePlanTier::Safe262k,
1495 stepfun_billing_route: StepfunBillingRoute::PayAsYouGo,
1496 pending_base_url: None,
1497 custom_provider_field: CustomProviderField::Name,
1498 custom_provider_id: String::new(),
1499 custom_provider_base_url: String::new(),
1500 custom_provider_model: String::new(),
1501 custom_provider_api_key_env: String::new(),
1502 };
1503 picker.restore_memory(memory);
1504 picker
1505 }
1506
1507 #[must_use]
1508 pub(crate) fn with_locale(mut self, locale: Locale) -> Self {
1509 self.locale = locale;
1510 self
1511 }
1512
1513 fn tr(&self, id: MessageId) -> Cow<'static, str> {
1514 tr(self.locale, id)
1515 }
1516
1517 /// Apply session-local request evidence after the static catalog rows are
1518 /// built. Saved credentials stay "not checked" until this snapshot proves
1519 /// success; a failed check remains visible and retryable with its reason.
1520 #[must_use]
1521 pub(crate) fn with_provider_health(mut self, health: &ProviderReadinessSnapshot) -> Self {
1522 for row in &mut self.rows {
1523 row.readiness = readiness_for(
1524 &row.route_identity,
1525 row.credential_state,
1526 row.route_ok,
1527 health,
1528 );
1529 if let Some(detail) = row.readiness.detail()
1530 && !row.messages.iter().any(|message| message == detail)
1531 {
1532 row.messages.push(detail.to_string());
1533 }
1534 }
1535 self
1536 }
1537
1538 /// Restore browsing context from the last dismissed `/provider` picker.
1539 fn restore_memory(&mut self, memory: Option<&crate::tui::app::ProviderPickerMemory>) {
1540 let Some(memory) = memory else {
1541 return;
1542 };
1543 if memory.catalog_view {
1544 self.view = ProviderListView::Catalog;
1545 }
1546 if let Some(remembered_id) = memory.selected_provider_id.as_deref()
1547 && let Some(idx) = self
1548 .rows
1549 .iter()
1550 .position(|row| row.provider_id == remembered_id)
1551 && (self.row_visible(idx) || memory.catalog_view)
1552 {
1553 if memory.catalog_view {
1554 self.view = ProviderListView::Catalog;
1555 }
1556 self.selected_idx = idx;
1557 }
1558 if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
1559 self.selected_idx = (0..self.rows.len())
1560 .find(|idx| self.row_visible(*idx))
1561 .unwrap_or(0);
1562 }
1563 }
1564
1565 /// Open the picker as a first-run/setup catalog: every built-in provider is
1566 /// visible, and an optional target is focused. Missing-auth targets jump
1567 /// straight to the existing masked key-entry stage; configured/local
1568 /// targets stay on the list so Enter applies them normally.
1569 #[must_use]
1570 pub fn new_for_setup(
1571 active: ApiProvider,
1572 target: Option<ApiProvider>,
1573 config: &Config,
1574 runtime_status: Option<ProviderRuntimeStatus>,
1575 ) -> Self {
1576 Self::new_for_setup_inner(active, target, config, runtime_status, true)
1577 }
1578
1579 /// Open the setup catalog for first-run/recovery onboarding (#4763).
1580 /// Identical to [`Self::new_for_setup`] except that a missing-auth
1581 /// `target` is only *focused*: onboarding must show the navigable
1582 /// provider list before it asks for a secret, so key/OAuth entry is
1583 /// reached by picking a row, never by opening straight into it.
1584 #[must_use]
1585 pub fn new_for_onboarding(
1586 active: ApiProvider,
1587 target: Option<ApiProvider>,
1588 config: &Config,
1589 runtime_status: Option<ProviderRuntimeStatus>,
1590 ) -> Self {
1591 Self::new_for_setup_inner(active, target, config, runtime_status, false)
1592 }
1593
1594 fn new_for_setup_inner(
1595 active: ApiProvider,
1596 target: Option<ApiProvider>,
1597 config: &Config,
1598 runtime_status: Option<ProviderRuntimeStatus>,
1599 key_entry_for_missing_auth: bool,
1600 ) -> Self {
1601 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
1602 picker.view = ProviderListView::Catalog;
1603 picker.setup_mode = true;
1604 if let Some(target) = target
1605 && let Some(idx) = picker.rows.iter().position(|row| row.provider == target)
1606 {
1607 picker.selected_idx = idx;
1608 if key_entry_for_missing_auth && !picker.selected_has_key() {
1609 picker.begin_setup();
1610 }
1611 }
1612 picker
1613 }
1614
1615 /// Open the picker already focused on `target` in its key-entry stage —
1616 /// the missing-auth handoff (#3830): when a route switch is rejected for
1617 /// want of a key, drop the user straight onto that provider's key prompt
1618 /// instead of dead-ending with an error. Falls back to the normal list
1619 /// if the target has no row (e.g. an unknown custom id).
1620 #[must_use]
1621 /// Returns `None` when `target` has no picker row (an unknown/custom
1622 /// provider we could not focus or key-enter) so the caller can keep its
1623 /// honest error instead of opening a dead-end picker.
1624 pub fn new_for_missing_auth(
1625 active: ApiProvider,
1626 target: ApiProvider,
1627 config: &Config,
1628 runtime_status: Option<ProviderRuntimeStatus>,
1629 ) -> Option<Self> {
1630 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
1631 let idx = picker.rows.iter().position(|row| row.provider == target)?;
1632 picker.selected_idx = idx;
1633 // The target may be an unconfigured catalog row; show the catalog so
1634 // it is visible, then jump into key entry for it.
1635 picker.view = ProviderListView::Catalog;
1636 picker.begin_setup();
1637 Some(picker)
1638 }
1639
1640 fn row_visible(&self, idx: usize) -> bool {
1641 let query = self.query.trim();
1642 if !query.is_empty() {
1643 return self.rows[idx].matches_query(query);
1644 }
1645 match self.view {
1646 ProviderListView::Catalog => true,
1647 ProviderListView::Configured => self.rows[idx].is_configured,
1648 }
1649 }
1650
1651 fn visible_row_count(&self) -> usize {
1652 (0..self.rows.len())
1653 .filter(|idx| self.row_visible(*idx))
1654 .count()
1655 }
1656
1657 /// Toggle between the configured-only and full-catalog views (#3830),
1658 /// keeping the current selection if it stays visible and otherwise
1659 /// jumping to the first visible row (`rows` is sorted alphabetically by
1660 /// display name, so this lands on the alphabetically-first match, not
1661 /// necessarily the row positionally nearest the old selection).
1662 fn toggle_view(&mut self) {
1663 self.view = match self.view {
1664 ProviderListView::Configured => ProviderListView::Catalog,
1665 ProviderListView::Catalog => ProviderListView::Configured,
1666 };
1667 if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
1668 self.selected_idx = (0..self.rows.len())
1669 .find(|idx| self.row_visible(*idx))
1670 .unwrap_or(0);
1671 }
1672 }
1673
1674 /// Update the search query and clamp the selection to the first visible row.
1675 fn update_query(&mut self, next: String) {
1676 self.query = next;
1677 self.selected_idx = (0..self.rows.len())
1678 .find(|idx| self.row_visible(*idx))
1679 .unwrap_or(0);
1680 }
1681
1682 /// Move the selection one visible row forward (`step = 1`) or backward
1683 /// (`step = -1`), skipping rows hidden by the current `view` filter
1684 /// (#3830) and wrapping at the ends.
1685 fn move_selection(&mut self, step: i64) {
1686 let count = self.rows.len();
1687 if count == 0 || self.visible_row_count() == 0 {
1688 return;
1689 }
1690 let mut idx = self.selected_idx;
1691 loop {
1692 idx = ((idx as i64 + step).rem_euclid(count as i64)) as usize;
1693 if self.row_visible(idx) {
1694 self.selected_idx = idx;
1695 return;
1696 }
1697 }
1698 }
1699
1700 fn move_up(&mut self) {
1701 self.move_selection(-1);
1702 }
1703
1704 fn move_down(&mut self) {
1705 self.move_selection(1);
1706 }
1707
1708 fn selected_provider(&self) -> ApiProvider {
1709 self.rows[self.selected_idx].provider
1710 }
1711
1712 fn selected_provider_id(&self) -> Option<String> {
1713 let row = &self.rows[self.selected_idx];
1714 (row.provider == ApiProvider::Custom).then(|| row.provider_id.clone())
1715 }
1716
1717 fn selected_has_key(&self) -> bool {
1718 matches!(
1719 self.rows[self.selected_idx].credential_state,
1720 CredentialState::Saved
1721 | CredentialState::ExternalConsent
1722 | CredentialState::ImportedToken
1723 | CredentialState::NoAuth
1724 | CredentialState::Local
1725 | CredentialState::Legacy
1726 )
1727 }
1728
1729 fn selected_route_is_valid(&self) -> bool {
1730 self.rows[self.selected_idx].route_ok
1731 }
1732
1733 fn enter_key_entry(&mut self) {
1734 self.stage = Stage::KeyEntry;
1735 self.api_key_input.clear();
1736 self.key_entry_error = None;
1737 self.pending_api_key = None;
1738 self.pending_base_url = None;
1739 self.model_options.clear();
1740 self.model_selected_idx = 0;
1741 self.selected_model = None;
1742 }
1743
1744 /// Start guided setup for the selected row. Providers that bill on more
1745 /// than one endpoint choose the route first so the key is validated
1746 /// against the endpoint it will actually be saved for (#4526).
1747 fn begin_setup(&mut self) {
1748 if self.selected_provider() == ApiProvider::Xai {
1749 self.enter_xai_auth_choice();
1750 } else if self.stepfun_billing_route_applies() {
1751 self.enter_stepfun_billing_route();
1752 } else {
1753 self.enter_key_entry();
1754 }
1755 }
1756
1757 fn enter_xai_auth_choice(&mut self) {
1758 self.xai_auth_choice = XaiAuthChoice::ApiKey;
1759 self.stage = Stage::XaiAuthChoice;
1760 self.api_key_input.clear();
1761 self.key_entry_error = None;
1762 self.pending_api_key = None;
1763 }
1764
1765 fn move_xai_auth_choice(&mut self) {
1766 self.xai_auth_choice = match self.xai_auth_choice {
1767 XaiAuthChoice::ApiKey => XaiAuthChoice::DeviceOAuth,
1768 XaiAuthChoice::DeviceOAuth => XaiAuthChoice::ApiKey,
1769 };
1770 }
1771
1772 fn stepfun_billing_route_applies(&self) -> bool {
1773 self.rows
1774 .get(self.selected_idx)
1775 .is_some_and(|row| stepfun_route_is_selectable(row.provider, &row.base_url))
1776 }
1777
1778 fn enter_stepfun_billing_route(&mut self) {
1779 // Preselect whatever the row already resolves to so re-running setup
1780 // on a configured Step Plan route does not default back to PAYG.
1781 self.stepfun_billing_route = if crate::pricing::billing_surface_for_route(
1782 ApiProvider::Stepfun,
1783 Some(&self.rows[self.selected_idx].base_url),
1784 ) == Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE)
1785 {
1786 StepfunBillingRoute::StepPlan
1787 } else {
1788 StepfunBillingRoute::PayAsYouGo
1789 };
1790 self.stage = Stage::StepfunBillingRoute;
1791 }
1792
1793 fn apply_stepfun_billing_route(&mut self) {
1794 let base_url = self.stepfun_billing_route.base_url().to_string();
1795 self.enter_key_entry();
1796 self.rows[self.selected_idx].base_url.clone_from(&base_url);
1797 self.pending_base_url = Some(base_url);
1798 }
1799
1800 fn selected_external_consent_target(
1801 &self,
1802 ) -> Option<(
1803 codewhale_config::ProviderKind,
1804 codewhale_config::ExternalCredentialSource,
1805 std::path::PathBuf,
1806 )> {
1807 external_consent_target_for_provider(self.selected_provider())
1808 }
1809
1810 fn enter_external_consent_choice(&mut self) {
1811 if self.selected_external_consent_target().is_some() {
1812 self.external_consent_choice = ExternalConsentChoice::Disabled;
1813 self.stage = Stage::ExternalConsentChoice;
1814 }
1815 }
1816
1817 fn move_external_consent_choice(&mut self, delta: isize) {
1818 let index = match self.external_consent_choice {
1819 ExternalConsentChoice::Disabled => 0,
1820 ExternalConsentChoice::ReadOnly => 1,
1821 ExternalConsentChoice::ManagedUnavailable => 2,
1822 };
1823 self.external_consent_choice = match (index as isize + delta).rem_euclid(3) {
1824 0 => ExternalConsentChoice::Disabled,
1825 1 => ExternalConsentChoice::ReadOnly,
1826 _ => ExternalConsentChoice::ManagedUnavailable,
1827 };
1828 }
1829
1830 fn build_external_consent_event(&self) -> Option<ViewEvent> {
1831 let (provider, source, path) = self.selected_external_consent_target()?;
1832 Some(ViewEvent::ProviderPickerExternalConsentConfirmed {
1833 provider: self.selected_provider(),
1834 consent_provider: provider,
1835 source,
1836 path,
1837 })
1838 }
1839
1840 /// Open the picker already focused on `target` in its key-entry stage
1841 /// with a validation error message - the verify-then-persist handoff
1842 /// (#3875): when a submitted key fails live validation, drop the user
1843 /// back on that provider's key prompt with the provider's actual error
1844 /// instead of dead-ending with a status toast.
1845 #[must_use]
1846 pub fn new_for_key_entry_with_error(
1847 active: ApiProvider,
1848 target: ApiProvider,
1849 config: &Config,
1850 runtime_status: Option<ProviderRuntimeStatus>,
1851 error: String,
1852 ) -> Option<Self> {
1853 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
1854 let idx = picker.rows.iter().position(|row| row.provider == target)?;
1855 picker.selected_idx = idx;
1856 picker.view = ProviderListView::Catalog;
1857 picker.stage = Stage::KeyEntry;
1858 picker.key_entry_error = Some(error);
1859 Some(picker)
1860 }
1861
1862 /// Open the guided flow on the model-pick stage after a key has been
1863 /// live-validated (#3875). The key stays in memory only until confirm.
1864 #[must_use]
1865 pub fn new_for_model_pick_after_validation(
1866 active: ApiProvider,
1867 target: ApiProvider,
1868 config: &Config,
1869 runtime_status: Option<ProviderRuntimeStatus>,
1870 api_key: String,
1871 base_url: Option<String>,
1872 ) -> Option<Self> {
1873 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
1874 let idx = picker.rows.iter().position(|row| row.provider == target)?;
1875 picker.selected_idx = idx;
1876 picker.view = ProviderListView::Catalog;
1877 picker.pending_api_key = Some(api_key);
1878 // The wizard's endpoint choice survives the validation round-trip so
1879 // confirm persists exactly the route the key was verified against.
1880 if let Some(base_url) = base_url {
1881 picker.rows[idx].base_url.clone_from(&base_url);
1882 picker.pending_base_url = Some(base_url);
1883 }
1884 picker.api_key_input.clear();
1885 picker.key_entry_error = None;
1886 picker.enter_model_pick();
1887 Some(picker)
1888 }
1889
1890 fn enter_model_pick(&mut self) {
1891 self.stage = Stage::ModelPick;
1892 self.selected_context_window = None;
1893 let provider = self.selected_provider();
1894 let route = &self.rows[self.selected_idx].default_route;
1895 let kimi_code_k3 = crate::config::is_exact_kimi_code_k3_route(
1896 provider,
1897 &self.rows[self.selected_idx].base_url,
1898 &route.wire_model,
1899 );
1900 // Recovery must restore the configured wire route, not replace bare
1901 // K3 with whichever generic Moonshot catalog entry happens to sort
1902 // first. Keep this route-local; `k3` is intentionally not added to
1903 // the global Moonshot catalog.
1904 let preferred = if kimi_code_k3 {
1905 route.wire_model.clone()
1906 } else {
1907 route.logical_model.clone()
1908 };
1909 let mut models = crate::provider_lake::all_catalog_models_for_provider(provider);
1910 if kimi_code_k3
1911 && !preferred.trim().is_empty()
1912 && !models
1913 .iter()
1914 .any(|model| model.eq_ignore_ascii_case(preferred.trim()))
1915 {
1916 models.push(preferred.clone());
1917 }
1918 if models.is_empty() && !preferred.trim().is_empty() {
1919 models.push(preferred.clone());
1920 }
1921 if models.is_empty() {
1922 // Last-resort so the guided flow never dead-ends without a choice.
1923 models.push(provider.as_str().to_string());
1924 }
1925 let selected = models
1926 .iter()
1927 .position(|model| model.eq_ignore_ascii_case(preferred.trim()))
1928 .unwrap_or(0);
1929 self.model_options = models;
1930 self.model_selected_idx = selected.min(self.model_options.len().saturating_sub(1));
1931 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
1932 }
1933
1934 fn enter_confirm(&mut self) {
1935 if self.selected_model.is_none() {
1936 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
1937 }
1938 self.stage = Stage::Confirm;
1939 }
1940
1941 fn selected_kimi_code_k3(&self) -> bool {
1942 let Some(model) = self.selected_model.as_deref() else {
1943 return false;
1944 };
1945 crate::config::is_exact_kimi_code_k3_route(
1946 self.selected_provider(),
1947 &self.rows[self.selected_idx].base_url,
1948 model,
1949 )
1950 }
1951
1952 fn enter_plan_tier(&mut self) {
1953 self.stage = Stage::PlanTier;
1954 self.kimi_code_plan_tier = KimiCodePlanTier::Safe262k;
1955 }
1956
1957 fn apply_plan_tier(&mut self) {
1958 self.selected_context_window = Some(match self.kimi_code_plan_tier {
1959 KimiCodePlanTier::Safe262k => crate::models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS,
1960 KimiCodePlanTier::OneMillion => 1_048_576,
1961 });
1962 self.enter_confirm();
1963 }
1964
1965 fn move_model_selection(&mut self, delta: isize) {
1966 let len = self.model_options.len();
1967 if len == 0 {
1968 return;
1969 }
1970 let current = self.model_selected_idx as isize;
1971 let next = (current + delta).rem_euclid(len as isize) as usize;
1972 self.model_selected_idx = next;
1973 self.selected_model = self.model_options.get(next).cloned();
1974 }
1975
1976 fn build_setup_confirmed_event(&self) -> Option<ViewEvent> {
1977 let api_key = self.pending_api_key.as_ref()?.trim();
1978 if api_key.is_empty() {
1979 return None;
1980 }
1981 let model = self
1982 .selected_model
1983 .as_ref()
1984 .map(|value| value.trim())
1985 .filter(|value| !value.is_empty())?;
1986 Some(ViewEvent::ProviderPickerSetupConfirmed {
1987 provider: self.selected_provider(),
1988 provider_id: self.selected_provider_id(),
1989 api_key: api_key.to_string(),
1990 model: model.to_string(),
1991 context_window: self.selected_context_window,
1992 base_url: self.pending_base_url.clone(),
1993 })
1994 }
1995
1996 fn enter_custom_form(&mut self) {
1997 self.stage = Stage::CustomForm;
1998 self.custom_provider_field = CustomProviderField::Name;
1999 self.custom_provider_id.clear();
2000 self.custom_provider_base_url.clear();
2001 self.custom_provider_model.clear();
2002 self.custom_provider_api_key_env.clear();
2003 }
2004
2005 fn custom_form_field_mut(&mut self) -> &mut String {
2006 match self.custom_provider_field {
2007 CustomProviderField::Name => &mut self.custom_provider_id,
2008 CustomProviderField::BaseUrl => &mut self.custom_provider_base_url,
2009 CustomProviderField::Model => &mut self.custom_provider_model,
2010 CustomProviderField::ApiKeyEnv => &mut self.custom_provider_api_key_env,
2011 }
2012 }
2013
2014 fn custom_form_field_value(&self, field: CustomProviderField) -> &str {
2015 match field {
2016 CustomProviderField::Name => &self.custom_provider_id,
2017 CustomProviderField::BaseUrl => &self.custom_provider_base_url,
2018 CustomProviderField::Model => &self.custom_provider_model,
2019 CustomProviderField::ApiKeyEnv => &self.custom_provider_api_key_env,
2020 }
2021 }
2022
2023 fn advance_custom_field(&mut self) {
2024 self.custom_provider_field = match self.custom_provider_field {
2025 CustomProviderField::Name => CustomProviderField::BaseUrl,
2026 CustomProviderField::BaseUrl => CustomProviderField::Model,
2027 CustomProviderField::Model => CustomProviderField::ApiKeyEnv,
2028 CustomProviderField::ApiKeyEnv => CustomProviderField::ApiKeyEnv,
2029 };
2030 }
2031
2032 fn retreat_custom_field(&mut self) {
2033 self.custom_provider_field = match self.custom_provider_field {
2034 CustomProviderField::Name => CustomProviderField::Name,
2035 CustomProviderField::BaseUrl => CustomProviderField::Name,
2036 CustomProviderField::Model => CustomProviderField::BaseUrl,
2037 CustomProviderField::ApiKeyEnv => CustomProviderField::Model,
2038 };
2039 }
2040
2041 fn build_custom_provider_event(&self) -> Option<ViewEvent> {
2042 let provider_id = self.custom_provider_id.trim();
2043 let base_url = self.custom_provider_base_url.trim();
2044 if provider_id.is_empty() || base_url.is_empty() {
2045 return None;
2046 }
2047 let model = non_empty_string(&self.custom_provider_model);
2048 let api_key_env = non_empty_string(&self.custom_provider_api_key_env);
2049 Some(ViewEvent::ProviderPickerCustomProviderSubmitted {
2050 provider_id: provider_id.to_string(),
2051 base_url: base_url.to_string(),
2052 model,
2053 api_key_env,
2054 })
2055 }
2056
2057 fn env_var_for(provider: ApiProvider) -> String {
2058 provider.env_vars_label()
2059 }
2060
2061 fn env_var_for_selected_row(&self) -> String {
2062 let row = &self.rows[self.selected_idx];
2063 if row.provider == ApiProvider::Custom {
2064 return row
2065 .messages
2066 .iter()
2067 .find_map(|message| {
2068 message
2069 .strip_prefix("missing ")
2070 .and_then(|rest| rest.split_once(" for custom provider"))
2071 .map(|(env_name, _)| env_name.to_string())
2072 })
2073 .unwrap_or_else(|| format!("[providers.{}] api_key", row.provider_id));
2074 }
2075 Self::env_var_for(row.provider)
2076 }
2077
2078 /// Rows visible under the current `view` filter (#3830), as
2079 /// `(original_index, row)` pairs so callers can still compare against
2080 /// `self.selected_idx`.
2081 fn filtered_rows(&self) -> Vec<(usize, &ProviderDashboardRow)> {
2082 self.rows
2083 .iter()
2084 .enumerate()
2085 .filter(|(idx, _)| self.row_visible(*idx))
2086 .collect()
2087 }
2088
2089 fn visible_start(selected_pos: usize, total: usize, visible_rows: usize) -> usize {
2090 if visible_rows == 0 {
2091 return 0;
2092 }
2093 let max_start = total.saturating_sub(visible_rows);
2094 selected_pos
2095 .saturating_add(1)
2096 .saturating_sub(visible_rows)
2097 .min(max_start)
2098 }
2099
2100 fn render_list(&self, area: Rect, buf: &mut Buffer) {
2101 let enter_action = if !self.selected_route_is_valid() {
2102 self.tr(MessageId::PickerActionUnavailable)
2103 } else if self.selected_has_key() {
2104 self.tr(MessageId::PickerActionApply)
2105 } else {
2106 self.tr(MessageId::PickerActionSetKey)
2107 };
2108 let title = match (self.setup_mode, self.view) {
2109 (true, ProviderListView::Configured) => {
2110 format!(" Provider setup{} ", catalog_freshness_title_suffix())
2111 }
2112 (true, ProviderListView::Catalog) => {
2113 format!(" Provider setup · all{} ", catalog_freshness_title_suffix())
2114 }
2115 (false, ProviderListView::Configured) => {
2116 format!(" Provider{} ", catalog_freshness_title_suffix())
2117 }
2118 (false, ProviderListView::Catalog) => {
2119 format!(" Provider · all{} ", catalog_freshness_title_suffix())
2120 }
2121 };
2122 let outer = Block::default()
2123 .title(Line::from(Span::styled(
2124 title,
2125 Style::default()
2126 .fg(palette::WHALE_INFO)
2127 .add_modifier(Modifier::BOLD),
2128 )))
2129 .borders(Borders::ALL)
2130 .border_style(Style::default().fg(palette::BORDER_COLOR))
2131 .style(Style::default().bg(palette::WHALE_BG));
2132 let inner = outer.inner(area);
2133 outer.render(area, buf);
2134
2135 let view_action = match self.view {
2136 ProviderListView::Configured => self.tr(MessageId::PickerActionBrowseAll),
2137 ProviderListView::Catalog => self.tr(MessageId::PickerActionConfigured),
2138 };
2139 let search_active = !self.query.trim().is_empty();
2140 // The action footer moves into the body so it wraps instead of clipping
2141 // at narrow widths (#3732); the provider list renders above it.
2142 let content = if search_active {
2143 render_modal_footer(
2144 inner,
2145 buf,
2146 &[
2147 // Two-stage Esc (clear the query, then cancel) reads as one
2148 // hint instead of a duplicated key.
2149 ActionHint::new(
2150 "Esc",
2151 format!(
2152 "{} / {}",
2153 self.tr(MessageId::PickerActionClear),
2154 self.tr(MessageId::PickerActionCancel)
2155 ),
2156 ),
2157 ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
2158 ActionHint::new("Enter", enter_action),
2159 ActionHint::new("A", view_action.clone()),
2160 ActionHint::new("C", self.tr(MessageId::PickerActionCustom)),
2161 ],
2162 )
2163 } else {
2164 render_modal_footer(
2165 inner,
2166 buf,
2167 &[
2168 ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
2169 ActionHint::new("a-z", self.tr(MessageId::PickerActionJump)),
2170 ActionHint::new("Enter", enter_action),
2171 ActionHint::new("A", view_action),
2172 ActionHint::new("C", self.tr(MessageId::PickerActionCustom)),
2173 ActionHint::new("R", self.tr(MessageId::PickerActionEditKey)),
2174 ActionHint::new("X", self.tr(MessageId::ProviderExternalActionRevoke)),
2175 ActionHint::new("M", self.tr(MessageId::PickerActionModels)),
2176 ActionHint::new("Esc", self.tr(MessageId::PickerActionCancel)),
2177 ],
2178 )
2179 };
2180
2181 let filtered = self.filtered_rows();
2182 if filtered.is_empty() {
2183 if search_active {
2184 EmptyState::new(
2185 self.tr(MessageId::ProviderNoMatchesTitle),
2186 self.tr(MessageId::ProviderNoMatchesHint),
2187 )
2188 .primary_action("Esc", self.tr(MessageId::PickerActionClearSearch))
2189 .render(content, buf);
2190 } else {
2191 EmptyState::new(
2192 self.tr(MessageId::ProviderNoConfiguredTitle),
2193 self.tr(MessageId::ProviderNoConfiguredHint),
2194 )
2195 .primary_action("A", self.tr(MessageId::PickerActionBrowseAll))
2196 .secondary_action("C", self.tr(MessageId::PickerActionCustom))
2197 .render(content, buf);
2198 }
2199 return;
2200 }
2201
2202 let layout = ListDetailLayout::split(content, 34);
2203 let selected_pos = filtered
2204 .iter()
2205 .position(|(idx, _)| *idx == self.selected_idx)
2206 .unwrap_or(0);
2207 let visible_rows = usize::from(layout.list.height);
2208 let visible_start = Self::visible_start(selected_pos, filtered.len(), visible_rows);
2209 let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
2210 for (pos, (idx, row)) in filtered
2211 .iter()
2212 .enumerate()
2213 .skip(visible_start)
2214 .take(visible_rows)
2215 {
2216 let is_selected = *idx == self.selected_idx;
2217 debug_assert_eq!(is_selected, pos == selected_pos);
2218 let is_active = row.is_active;
2219 let arrow = crate::tui::glyphs::selection_marker(is_selected);
2220 let active_dot = if is_active { " *" } else { " " };
2221 let spacer_style = if is_selected {
2222 menu_style::selected_row_bg_style()
2223 } else {
2224 Style::default()
2225 };
2226 let label_style = if is_selected {
2227 menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
2228 } else {
2229 Style::default().fg(palette::TEXT_PRIMARY)
2230 };
2231 let has_usable_auth = matches!(
2232 row.credential_state,
2233 CredentialState::Saved
2234 | CredentialState::ImportedToken
2235 | CredentialState::NoAuth
2236 | CredentialState::Local
2237 | CredentialState::Legacy
2238 );
2239 let hint_style = if is_selected {
2240 let hint_fg = if has_usable_auth {
2241 palette::TEXT_MUTED
2242 } else {
2243 palette::STATUS_WARNING
2244 };
2245 menu_style::selected_row_style_with_fg(hint_fg)
2246 } else if has_usable_auth {
2247 Style::default().fg(palette::TEXT_MUTED)
2248 } else {
2249 Style::default().fg(palette::STATUS_WARNING)
2250 };
2251 let prefix = format!(" {arrow} {}{active_dot} ", row.display_name);
2252 let hint = crate::tui::ui_text::semantic_truncate_between_affixes(
2253 &prefix,
2254 &row.list_row_hint(self.view),
2255 "",
2256 usize::from(layout.list.width),
2257 );
2258 let mut line = Line::from(vec![
2259 Span::styled(" ", spacer_style),
2260 Span::styled(arrow, label_style),
2261 Span::styled(" ", spacer_style),
2262 Span::styled(row.display_name.as_str(), label_style),
2263 Span::styled(active_dot, label_style),
2264 Span::styled(" ", spacer_style),
2265 Span::styled(hint, hint_style),
2266 ]);
2267 if is_selected {
2268 line.style = menu_style::selected_row_bg_style();
2269 let target_width = usize::from(layout.list.width);
2270 let line_width = line.width();
2271 if line_width < target_width {
2272 line.spans.push(Span::styled(
2273 " ".repeat(target_width - line_width),
2274 menu_style::selected_row_bg_style(),
2275 ));
2276 }
2277 }
2278 lines.push(line);
2279 }
2280 Paragraph::new(lines).render(layout.list, buf);
2281 self.render_provider_detail(layout.detail, buf, &self.rows[self.selected_idx]);
2282 }
2283
2284 fn render_provider_detail(&self, area: Rect, buf: &mut Buffer, row: &ProviderDashboardRow) {
2285 if area.width == 0 || area.height == 0 {
2286 return;
2287 }
2288 let block = Block::default()
2289 .title(Line::from(Span::styled(
2290 " Details ",
2291 Style::default()
2292 .fg(palette::TEXT_PRIMARY)
2293 .add_modifier(Modifier::BOLD),
2294 )))
2295 .borders(Borders::ALL)
2296 .border_style(Style::default().fg(palette::BORDER_COLOR))
2297 .style(Style::default());
2298 let inner = block.inner(area);
2299 block.render(area, buf);
2300
2301 let route = if row.default_route.logical_model == row.default_route.wire_model {
2302 row.default_route.logical_model.clone()
2303 } else {
2304 format!(
2305 "{} -> {}",
2306 row.default_route.logical_model, row.default_route.wire_model
2307 )
2308 };
2309 let mut lines = vec![
2310 Line::from(Span::styled(
2311 row.display_name.clone(),
2312 Style::default()
2313 .fg(palette::TEXT_PRIMARY)
2314 .add_modifier(Modifier::BOLD),
2315 )),
2316 Line::from(Span::styled(
2317 format!(
2318 "{} | {} | {}",
2319 row.readiness.label(),
2320 row.auth_status.label(),
2321 row.catalog_label()
2322 ),
2323 Style::default().fg(palette::TEXT_MUTED),
2324 )),
2325 Line::from(Span::styled(
2326 format!("Route: {route}"),
2327 Style::default().fg(palette::TEXT_PRIMARY),
2328 )),
2329 Line::from(Span::styled(
2330 format!("Endpoint: {}", row.base_url),
2331 Style::default().fg(palette::TEXT_MUTED),
2332 )),
2333 Line::from(Span::styled(
2334 format!(
2335 "Protocol: {} | Usage: {}",
2336 row.supported_protocols.join("+"),
2337 row.usage_meter
2338 ),
2339 Style::default().fg(palette::TEXT_MUTED),
2340 )),
2341 Line::from(Span::styled(
2342 format!("Capabilities: {}", row.capabilities.label()),
2343 Style::default().fg(palette::TEXT_MUTED),
2344 )),
2345 Line::from(Span::styled(
2346 format!("Reasoning: {}", row.reasoning.label()),
2347 Style::default().fg(palette::TEXT_MUTED),
2348 )),
2349 ];
2350 if let Some(concurrency) = row.request_concurrency.label() {
2351 lines.push(Line::from(Span::styled(
2352 concurrency,
2353 Style::default().fg(palette::TEXT_MUTED),
2354 )));
2355 }
2356 for message in row.messages.iter().take(2) {
2357 lines.push(Line::from(Span::styled(
2358 format!("Note: {message}"),
2359 Style::default().fg(palette::STATUS_WARNING),
2360 )));
2361 }
2362 if let Some(status) = row.external_credential_status.as_ref() {
2363 let state = if status.route_state == "active" {
2364 self.tr(MessageId::CtxInspActive)
2365 } else {
2366 self.tr(MessageId::ProviderExternalDormant)
2367 };
2368 let scope = self
2369 .tr(MessageId::ProviderExternalDetailScope)
2370 .replace("{access}", status.access.as_str())
2371 .replace("{provider}", &status.provider)
2372 .replace("{source}", status.source.as_str())
2373 .replace("{version}", &status.consent_version.to_string())
2374 .replace("{state}", &state);
2375 lines.push(Line::from(Span::styled(
2376 scope,
2377 Style::default().fg(palette::TEXT_MUTED),
2378 )));
2379 let owner_path = self
2380 .tr(MessageId::ProviderExternalOwnerPath)
2381 .replace("{owner}", status.owner)
2382 .replace("{path}", &codewhale_config::quote_os_path(&status.path));
2383 let mut owner_path_spans = vec![Span::styled(
2384 owner_path,
2385 Style::default().fg(palette::TEXT_MUTED),
2386 )];
2387 if status.ambient_path_changed {
2388 let warning = self
2389 .tr(MessageId::ProviderExternalPinnedPathWarning)
2390 .replace("{owner}", status.owner)
2391 .replace("{path}", &codewhale_config::quote_os_path(&status.path));
2392 owner_path_spans.push(Span::styled(
2393 " | ",
2394 Style::default().fg(palette::TEXT_MUTED),
2395 ));
2396 owner_path_spans.push(Span::styled(
2397 warning,
2398 Style::default().fg(palette::STATUS_WARNING),
2399 ));
2400 }
2401 lines.push(Line::from(owner_path_spans));
2402 let semantics = match status.access {
2403 codewhale_config::ExternalCredentialAccess::Disabled => {
2404 self.tr(MessageId::ProviderExternalDisabledDetail)
2405 }
2406 codewhale_config::ExternalCredentialAccess::ReadOnly => {
2407 self.tr(MessageId::ProviderExternalReadOnlySemantics)
2408 }
2409 codewhale_config::ExternalCredentialAccess::Managed => {
2410 self.tr(MessageId::ProviderExternalManagedDetail)
2411 }
2412 };
2413 lines.push(Line::from(Span::styled(
2414 semantics,
2415 Style::default().fg(palette::TEXT_MUTED),
2416 )));
2417 let revoke = self
2418 .tr(MessageId::ProviderExternalRevoke)
2419 .replace("{revoke}", &status.revoke_command);
2420 lines.push(Line::from(Span::styled(
2421 revoke,
2422 Style::default().fg(palette::TEXT_MUTED),
2423 )));
2424 }
2425 Paragraph::new(lines)
2426 .wrap(Wrap { trim: true })
2427 .render(inner, buf);
2428 }
2429
2430 fn render_xai_auth_choice(&self, area: Rect, buf: &mut Buffer) {
2431 let outer = Block::default()
2432 .title(Line::from(Span::styled(
2433 self.tr(MessageId::XaiAuthChoiceTitle),
2434 Style::default()
2435 .fg(palette::WHALE_INFO)
2436 .add_modifier(Modifier::BOLD),
2437 )))
2438 .borders(Borders::ALL)
2439 .border_style(Style::default().fg(palette::BORDER_COLOR))
2440 .style(Style::default().bg(palette::WHALE_BG));
2441 let inner = outer.inner(area);
2442 outer.render(area, buf);
2443 let content = render_modal_footer(
2444 inner,
2445 buf,
2446 &[
2447 ActionHint::new("↑↓/1-2", self.tr(MessageId::ProviderExternalActionChoose)),
2448 ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
2449 ActionHint::new("E", self.tr(MessageId::ProviderExternalActionReuseGrok)),
2450 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
2451 ],
2452 );
2453 let marker = |choice| crate::tui::glyphs::selection_marker(self.xai_auth_choice == choice);
2454 Paragraph::new(vec![
2455 Line::from(self.tr(MessageId::XaiAuthChoiceIntro)),
2456 Line::from(""),
2457 Line::from(format!(
2458 "{} 1. {}",
2459 marker(XaiAuthChoice::ApiKey),
2460 self.tr(MessageId::XaiAuthChoiceApiKeyOption),
2461 )),
2462 Line::from(format!(
2463 "{} 2. {}",
2464 marker(XaiAuthChoice::DeviceOAuth),
2465 self.tr(MessageId::XaiAuthChoiceDeviceOAuthOption),
2466 )),
2467 ])
2468 .wrap(Wrap { trim: false })
2469 .render(content, buf);
2470 }
2471
2472 fn render_key_entry(&self, area: Rect, buf: &mut Buffer) {
2473 let row = &self.rows[self.selected_idx];
2474 let codex_oauth = row.provider == ApiProvider::OpenaiCodex;
2475 let oauth_provider = codex_oauth;
2476 let saved_credential = !oauth_provider && row.has_key;
2477 let outer = Block::default()
2478 .title(Line::from(Span::styled(
2479 if oauth_provider {
2480 format!(" OAuth login — {} ", row.display_name)
2481 } else {
2482 format!(" API key — {} ", row.display_name)
2483 },
2484 Style::default()
2485 .fg(palette::WHALE_INFO)
2486 .add_modifier(Modifier::BOLD),
2487 )))
2488 .borders(Borders::ALL)
2489 .border_style(Style::default().fg(palette::BORDER_COLOR))
2490 .style(Style::default().bg(palette::WHALE_BG));
2491 let inner = outer.inner(area);
2492 outer.render(area, buf);
2493
2494 // The action footer moves into the body so it wraps instead of clipping
2495 // at narrow widths (#3732); the key-entry fields render above it.
2496 let content = if codex_oauth {
2497 render_modal_footer(
2498 inner,
2499 buf,
2500 &[
2501 ActionHint::new("Enter", self.tr(MessageId::ProviderExternalActionChoices)),
2502 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
2503 ],
2504 )
2505 } else if saved_credential && self.api_key_input.trim().is_empty() {
2506 render_modal_footer(
2507 inner,
2508 buf,
2509 &[
2510 ActionHint::new("Type/paste", "replace saved key"),
2511 ActionHint::new("Esc", "keep current key"),
2512 ],
2513 )
2514 } else {
2515 render_modal_footer(
2516 inner,
2517 buf,
2518 &[
2519 ActionHint::new("Enter", "continue"),
2520 ActionHint::new("Esc", "back"),
2521 ],
2522 )
2523 };
2524
2525 let masked = mask_key(&self.api_key_input);
2526 let display = if codex_oauth {
2527 "(run codex login; then explicitly grant read-only access)".to_string()
2528 } else if masked.is_empty() && saved_credential {
2529 "Saved credential configured".to_string()
2530 } else if masked.is_empty() {
2531 "(paste key here)".to_string()
2532 } else {
2533 masked
2534 };
2535 let key_lines = vec![Line::from(vec![
2536 Span::styled(
2537 if oauth_provider { "Auth: " } else { "Key: " },
2538 Style::default().fg(palette::TEXT_MUTED),
2539 ),
2540 Span::styled(
2541 display,
2542 Style::default()
2543 .fg(palette::TEXT_PRIMARY)
2544 .add_modifier(Modifier::BOLD),
2545 ),
2546 ])];
2547 let reopen_command = if self.setup_mode {
2548 "/setup provider"
2549 } else {
2550 "/provider"
2551 };
2552 let mut hint_lines = if codex_oauth {
2553 vec![
2554 Line::from(Span::styled(
2555 self.tr(MessageId::ProviderExternalHintCodexReview)
2556 .replace("{login}", "codex login"),
2557 Style::default().fg(palette::TEXT_MUTED),
2558 )),
2559 Line::from(Span::styled(
2560 format!(
2561 "Or set {} / CODEX_ACCESS_TOKEN and re-open {reopen_command}.",
2562 self.env_var_for_selected_row(),
2563 ),
2564 Style::default().fg(palette::TEXT_MUTED),
2565 )),
2566 Line::from(Span::styled(
2567 "CLI: codewhale auth external-consent --provider openai-codex; no token is stored here.",
2568 Style::default().fg(palette::TEXT_MUTED),
2569 )),
2570 ]
2571 } else if saved_credential && self.api_key_input.trim().is_empty() {
2572 vec![Line::from(Span::styled(
2573 "This terminal can use the stored credential. Type or paste only to replace it; Esc keeps it unchanged.",
2574 Style::default().fg(palette::TEXT_MUTED),
2575 ))]
2576 } else if saved_credential {
2577 vec![Line::from(Span::styled(
2578 "The replacement is validated before it replaces the stored credential.",
2579 Style::default().fg(palette::TEXT_MUTED),
2580 ))]
2581 } else {
2582 vec![Line::from(Span::styled(
2583 format!(
2584 "Or set the {} environment variable and re-open {reopen_command}.",
2585 self.env_var_for_selected_row(),
2586 ),
2587 Style::default().fg(palette::TEXT_MUTED),
2588 ))]
2589 };
2590 if !oauth_provider {
2591 if row.provider == ApiProvider::Moonshot
2592 && crate::config::moonshot_base_url_is_exact_kimi_code(&row.base_url)
2593 {
2594 hint_lines.extend([
2595 Line::from(Span::styled(
2596 self.tr(MessageId::KimiCodePlanApiKeyHint).replace(
2597 "{console}",
2598 crate::config::KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL,
2599 ),
2600 Style::default().fg(palette::TEXT_MUTED),
2601 )),
2602 Line::from(Span::styled(
2603 self.tr(MessageId::KimiCodePlanRouteHint)
2604 .replace("{route}", crate::config::DEFAULT_KIMI_CODE_BASE_URL),
2605 Style::default().fg(palette::TEXT_MUTED),
2606 )),
2607 Line::from(Span::styled(
2608 self.tr(MessageId::KimiCodePlanNoImportHint),
2609 Style::default().fg(palette::TEXT_MUTED),
2610 )),
2611 ]);
2612 } else {
2613 let help = row.provider.credential_help();
2614 hint_lines.push(Line::from(Span::styled(
2615 help.credential_url.map_or_else(
2616 || format!("Credentials: {}", help.guidance),
2617 |url| format!("Credentials: {url}"),
2618 ),
2619 Style::default().fg(palette::TEXT_MUTED),
2620 )));
2621 }
2622 };
2623
2624 if let Some(ref error) = self.key_entry_error {
2625 hint_lines.push(Line::from(Span::styled(
2626 format!("Verification failed: {error}"),
2627 Style::default().fg(palette::STATUS_ERROR),
2628 )));
2629 }
2630
2631 // `Line` count is not rendered row count: long environment-variable
2632 // guidance can wrap to two or three terminal rows. Ask ratatui for the
2633 // exact wrapped height instead of duplicating its layout arithmetic.
2634 let hint = Paragraph::new(hint_lines).wrap(Wrap { trim: true });
2635 let hint_height = u16::try_from(hint.line_count(content.width.max(1)))
2636 .unwrap_or(u16::MAX)
2637 .clamp(1, 6);
2638 let layout = Layout::default()
2639 .direction(Direction::Vertical)
2640 .constraints([
2641 Constraint::Length(3),
2642 Constraint::Length(hint_height),
2643 Constraint::Min(1),
2644 ])
2645 .split(content);
2646
2647 Paragraph::new(key_lines).render(layout[0], buf);
2648 hint.render(layout[1], buf);
2649 }
2650
2651 fn render_external_consent_choice(&self, area: Rect, buf: &mut Buffer) {
2652 let provider_name = self.rows[self.selected_idx].display_name.clone();
2653 let outer = Block::default()
2654 .title(Line::from(Span::styled(
2655 self.tr(MessageId::ProviderExternalChoiceTitle)
2656 .replace("{provider}", &provider_name),
2657 Style::default()
2658 .fg(palette::WHALE_INFO)
2659 .add_modifier(Modifier::BOLD),
2660 )))
2661 .borders(Borders::ALL)
2662 .border_style(Style::default().fg(palette::BORDER_COLOR))
2663 .style(Style::default().bg(palette::WHALE_BG));
2664 let inner = outer.inner(area);
2665 outer.render(area, buf);
2666 let content = render_modal_footer(
2667 inner,
2668 buf,
2669 &[
2670 ActionHint::new("↑↓", self.tr(MessageId::ProviderExternalActionChoose)),
2671 ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
2672 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
2673 ],
2674 );
2675 let selected = self.external_consent_choice;
2676 let row = |choice, label: Cow<'static, str>, detail: Cow<'static, str>| {
2677 let marker = crate::tui::glyphs::selection_marker(selected == choice);
2678 Line::from(vec![
2679 Span::styled(
2680 format!("{marker} {label}"),
2681 Style::default().fg(if selected == choice {
2682 palette::WHALE_INFO
2683 } else {
2684 palette::TEXT_PRIMARY
2685 }),
2686 ),
2687 Span::styled(
2688 format!(" · {detail}"),
2689 Style::default().fg(palette::TEXT_MUTED),
2690 ),
2691 ])
2692 };
2693 Paragraph::new(vec![
2694 Line::from(self.tr(MessageId::ProviderExternalChoiceIntro)),
2695 Line::from(""),
2696 row(
2697 ExternalConsentChoice::Disabled,
2698 self.tr(MessageId::ProviderExternalDisabledLabel),
2699 self.tr(MessageId::ProviderExternalDisabledDetail),
2700 ),
2701 row(
2702 ExternalConsentChoice::ReadOnly,
2703 self.tr(MessageId::ProviderExternalReadOnlyLabel),
2704 self.tr(MessageId::ProviderExternalReadOnlyDetail),
2705 ),
2706 row(
2707 ExternalConsentChoice::ManagedUnavailable,
2708 self.tr(MessageId::ProviderExternalManagedLabel),
2709 self.tr(MessageId::ProviderExternalManagedDetail),
2710 ),
2711 ])
2712 .wrap(Wrap { trim: false })
2713 .render(content, buf);
2714 }
2715
2716 fn render_external_consent_confirm(&self, area: Rect, buf: &mut Buffer) {
2717 let Some((provider, source, path)) = self.selected_external_consent_target() else {
2718 return;
2719 };
2720 let outer = Block::default()
2721 .title(Line::from(Span::styled(
2722 self.tr(MessageId::ProviderExternalConfirmTitle),
2723 Style::default()
2724 .fg(palette::WHALE_INFO)
2725 .add_modifier(Modifier::BOLD),
2726 )))
2727 .borders(Borders::ALL)
2728 .border_style(Style::default().fg(palette::BORDER_COLOR))
2729 .style(Style::default().bg(palette::WHALE_BG));
2730 let inner = outer.inner(area);
2731 outer.render(area, buf);
2732 let content = render_modal_footer(
2733 inner,
2734 buf,
2735 &[
2736 ActionHint::new("Enter", self.tr(MessageId::ProviderExternalActionGrant)),
2737 ActionHint::new("Esc", self.tr(MessageId::SetupActionCancel)),
2738 ],
2739 );
2740 let provider_label = self.tr(MessageId::RouteProviderLabel);
2741 let owner_label = self.tr(MessageId::ProviderExternalOwnerLabel);
2742 let exact_path_label = self.tr(MessageId::ProviderExternalExactPathLabel);
2743 let semantics_label = self.tr(MessageId::ProviderExternalSemanticsLabel);
2744 let revoke_label = self.tr(MessageId::ProviderExternalRevokeLabel);
2745 Paragraph::new(vec![
2746 Line::from(format!("{provider_label}: {}", provider.as_str())),
2747 Line::from(format!(
2748 "{owner_label}: {} ({})",
2749 source.owner_label(),
2750 source.as_str()
2751 )),
2752 Line::from(format!(
2753 "{exact_path_label}: {}",
2754 codewhale_config::quote_os_path(&path)
2755 )),
2756 Line::from(""),
2757 Line::from(format!(
2758 "{semantics_label}: {}.",
2759 self.tr(MessageId::ProviderExternalReadOnlySemantics)
2760 )),
2761 Line::from(self.tr(MessageId::ProviderExternalRejectUnsafe)),
2762 Line::from(format!(
2763 "{revoke_label}: codewhale auth external-revoke --provider {}",
2764 provider.as_str()
2765 )),
2766 ])
2767 .wrap(Wrap { trim: false })
2768 .render(content, buf);
2769 }
2770
2771 fn render_model_pick(&self, area: Rect, buf: &mut Buffer) {
2772 let provider_name = self.rows[self.selected_idx].display_name.clone();
2773 let outer = Block::default()
2774 .title(Line::from(Span::styled(
2775 format!(" Default model · {provider_name} "),
2776 Style::default()
2777 .fg(palette::WHALE_INFO)
2778 .add_modifier(Modifier::BOLD),
2779 )))
2780 .borders(Borders::ALL)
2781 .border_style(Style::default().fg(palette::BORDER_COLOR))
2782 .style(Style::default().bg(palette::WHALE_BG));
2783 let inner = outer.inner(area);
2784 outer.render(area, buf);
2785
2786 let content = render_modal_footer(
2787 inner,
2788 buf,
2789 &[
2790 ActionHint::new("↑↓", "move"),
2791 ActionHint::new("Enter", "continue"),
2792 ActionHint::new("Esc", "back"),
2793 ],
2794 );
2795
2796 let header = Paragraph::new(Line::from(Span::styled(
2797 "Key verified. Pick a default model for this provider.",
2798 Style::default().fg(palette::TEXT_MUTED),
2799 )));
2800 let layout = Layout::default()
2801 .direction(Direction::Vertical)
2802 .constraints([Constraint::Length(1), Constraint::Min(1)])
2803 .split(content);
2804 header.render(layout[0], buf);
2805
2806 let list_area = layout[1];
2807 let visible_rows = usize::from(list_area.height);
2808 let visible_start = Self::visible_start(
2809 self.model_selected_idx,
2810 self.model_options.len(),
2811 visible_rows,
2812 );
2813 let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
2814 for (idx, model) in self
2815 .model_options
2816 .iter()
2817 .enumerate()
2818 .skip(visible_start)
2819 .take(visible_rows)
2820 {
2821 let is_selected = idx == self.model_selected_idx;
2822 let arrow = crate::tui::glyphs::selection_marker(is_selected);
2823 let label_style = if is_selected {
2824 menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
2825 } else {
2826 Style::default().fg(palette::TEXT_PRIMARY)
2827 };
2828 let default_tag = if self.rows[self.selected_idx]
2829 .default_route
2830 .logical_model
2831 .eq_ignore_ascii_case(model)
2832 {
2833 "default"
2834 } else {
2835 ""
2836 };
2837 let mut line = Line::from(vec![
2838 Span::styled(format!(" {arrow} {model}"), label_style),
2839 if default_tag.is_empty() {
2840 Span::raw("")
2841 } else {
2842 Span::styled(
2843 format!(" ({default_tag})"),
2844 if is_selected {
2845 menu_style::selected_row_style_with_fg(palette::TEXT_MUTED)
2846 } else {
2847 Style::default().fg(palette::TEXT_MUTED)
2848 },
2849 )
2850 },
2851 ]);
2852 if is_selected {
2853 line.style = menu_style::selected_row_bg_style();
2854 }
2855 lines.push(line);
2856 }
2857 if lines.is_empty() {
2858 lines.push(Line::from(Span::styled(
2859 self.tr(MessageId::ProviderNoCatalogModels),
2860 Style::default().fg(palette::TEXT_MUTED),
2861 )));
2862 }
2863 Paragraph::new(lines).render(list_area, buf);
2864 }
2865
2866 fn render_plan_tier(&self, area: Rect, buf: &mut Buffer) {
2867 let outer = Block::default()
2868 .title(Line::from(Span::styled(
2869 " Kimi Code plan tier ",
2870 Style::default()
2871 .fg(palette::WHALE_INFO)
2872 .add_modifier(Modifier::BOLD),
2873 )))
2874 .borders(Borders::ALL)
2875 .border_style(Style::default().fg(palette::BORDER_COLOR))
2876 .style(Style::default().bg(palette::WHALE_BG));
2877 let inner = outer.inner(area);
2878 outer.render(area, buf);
2879 let content = render_modal_footer(
2880 inner,
2881 buf,
2882 &[
2883 ActionHint::new("↑↓", "choose"),
2884 ActionHint::new("Enter", "continue"),
2885 ActionHint::new("Esc", "back"),
2886 ],
2887 );
2888 let selected = self.kimi_code_plan_tier;
2889 let marker = |tier| crate::tui::glyphs::selection_marker(selected == tier);
2890 Paragraph::new(vec![
2891 Line::from("Kimi Code plan limits determine the context window used for k3."),
2892 Line::from("Choose the tier you actually have; the safe floor is selected by default."),
2893 Line::from(""),
2894 Line::from(format!(
2895 "{} 1. 262K context (safe default)",
2896 marker(KimiCodePlanTier::Safe262k)
2897 )),
2898 Line::from(format!(
2899 "{} 2. 1M context (only with an eligible plan)",
2900 marker(KimiCodePlanTier::OneMillion)
2901 )),
2902 ])
2903 .wrap(Wrap { trim: false })
2904 .render(content, buf);
2905 }
2906
2907 fn render_stepfun_billing_route(&self, area: Rect, buf: &mut Buffer) {
2908 let outer = Block::default()
2909 .title(Line::from(Span::styled(
2910 format!(" {} ", self.tr(MessageId::StepfunBillingRouteTitle)),
2911 Style::default()
2912 .fg(palette::WHALE_INFO)
2913 .add_modifier(Modifier::BOLD),
2914 )))
2915 .borders(Borders::ALL)
2916 .border_style(Style::default().fg(palette::BORDER_COLOR))
2917 .style(Style::default().bg(palette::WHALE_BG));
2918 let inner = outer.inner(area);
2919 outer.render(area, buf);
2920 let content = render_modal_footer(
2921 inner,
2922 buf,
2923 &[
2924 ActionHint::new("↑↓", "choose"),
2925 ActionHint::new("Enter", "continue"),
2926 ActionHint::new("Esc", "back"),
2927 ],
2928 );
2929 let selected = self.stepfun_billing_route;
2930 let marker = |route| crate::tui::glyphs::selection_marker(selected == route);
2931 // The endpoint is shown next to each choice: it is the whole
2932 // difference between the two billing tracks, and it is what gets
2933 // written to `[providers.stepfun] base_url` on confirm.
2934 Paragraph::new(vec![
2935 Line::from(self.tr(MessageId::StepfunBillingRouteIntro).to_string()),
2936 Line::from(""),
2937 Line::from(format!(
2938 "{} 1. {} — {}",
2939 marker(StepfunBillingRoute::PayAsYouGo),
2940 self.tr(MessageId::StepfunBillingRoutePaygOption),
2941 StepfunBillingRoute::PayAsYouGo.base_url(),
2942 )),
2943 Line::from(format!(
2944 "{} 2. {} — {}",
2945 marker(StepfunBillingRoute::StepPlan),
2946 self.tr(MessageId::StepfunBillingRoutePlanOption),
2947 StepfunBillingRoute::StepPlan.base_url(),
2948 )),
2949 ])
2950 .wrap(Wrap { trim: false })
2951 .render(content, buf);
2952 }
2953
2954 fn render_confirm(&self, area: Rect, buf: &mut Buffer) {
2955 let row = &self.rows[self.selected_idx];
2956 let outer = Block::default()
2957 .title(Line::from(Span::styled(
2958 " Confirm provider setup ",
2959 Style::default()
2960 .fg(palette::WHALE_INFO)
2961 .add_modifier(Modifier::BOLD),
2962 )))
2963 .borders(Borders::ALL)
2964 .border_style(Style::default().fg(palette::BORDER_COLOR))
2965 .style(Style::default().bg(palette::WHALE_BG));
2966 let inner = outer.inner(area);
2967 outer.render(area, buf);
2968
2969 let content = render_modal_footer(
2970 inner,
2971 buf,
2972 &[
2973 ActionHint::new("Enter", "save & switch"),
2974 ActionHint::new("Esc", "back"),
2975 ],
2976 );
2977
2978 let masked = self
2979 .pending_api_key
2980 .as_deref()
2981 .map(mask_key)
2982 .filter(|value| !value.is_empty())
2983 .unwrap_or_else(|| "(none)".to_string());
2984 let model = self
2985 .selected_model
2986 .as_deref()
2987 .filter(|value| !value.trim().is_empty())
2988 .unwrap_or("(none)");
2989 let lines = vec![
2990 Line::from(Span::styled(
2991 "Review before saving. Nothing is written until you confirm.",
2992 Style::default().fg(palette::TEXT_MUTED),
2993 )),
2994 Line::from(vec![
2995 Span::styled("Provider: ", Style::default().fg(palette::TEXT_MUTED)),
2996 Span::styled(
2997 row.display_name.clone(),
2998 Style::default()
2999 .fg(palette::TEXT_PRIMARY)
3000 .add_modifier(Modifier::BOLD),
3001 ),
3002 ]),
3003 Line::from(vec![
3004 Span::styled("API key: ", Style::default().fg(palette::TEXT_MUTED)),
3005 Span::styled(masked, Style::default().fg(palette::TEXT_PRIMARY)),
3006 ]),
3007 Line::from(vec![
3008 Span::styled("Model: ", Style::default().fg(palette::TEXT_MUTED)),
3009 Span::styled(
3010 model.to_string(),
3011 Style::default()
3012 .fg(palette::TEXT_PRIMARY)
3013 .add_modifier(Modifier::BOLD),
3014 ),
3015 ]),
3016 if let Some(context_window) = self.selected_context_window {
3017 Line::from(format!("Context: {} tokens", context_window))
3018 } else {
3019 Line::from("")
3020 },
3021 ];
3022 Paragraph::new(lines).render(content, buf);
3023 }
3024
3025 fn render_custom_form(&self, area: Rect, buf: &mut Buffer) {
3026 let outer = Block::default()
3027 .title(Line::from(Span::styled(
3028 " Custom provider ",
3029 Style::default()
3030 .fg(palette::WHALE_INFO)
3031 .add_modifier(Modifier::BOLD),
3032 )))
3033 .borders(Borders::ALL)
3034 .border_style(Style::default().fg(palette::BORDER_COLOR))
3035 .style(Style::default().bg(palette::WHALE_BG));
3036 let inner = outer.inner(area);
3037 outer.render(area, buf);
3038
3039 let content = render_modal_footer(
3040 inner,
3041 buf,
3042 &[
3043 ActionHint::new("Tab/↑↓", "field"),
3044 ActionHint::new("Enter", "next/save"),
3045 ActionHint::new("Esc", "back"),
3046 ],
3047 );
3048 let layout = Layout::default()
3049 .direction(Direction::Vertical)
3050 .constraints([
3051 Constraint::Length(1),
3052 Constraint::Length(1),
3053 Constraint::Length(1),
3054 Constraint::Length(1),
3055 Constraint::Length(1),
3056 Constraint::Min(0),
3057 ])
3058 .split(content);
3059
3060 Paragraph::new(Line::from(Span::styled(
3061 "OpenAI-compatible endpoint. Store an env var name here, not a raw key.",
3062 Style::default().fg(palette::TEXT_MUTED),
3063 )))
3064 .render(layout[0], buf);
3065
3066 self.render_custom_form_field(layout[1], buf, CustomProviderField::Name, "Name", "acme_ai");
3067 self.render_custom_form_field(
3068 layout[2],
3069 buf,
3070 CustomProviderField::BaseUrl,
3071 "Base URL",
3072 "https://api.example.com/v1",
3073 );
3074 self.render_custom_form_field(
3075 layout[3],
3076 buf,
3077 CustomProviderField::Model,
3078 "Default model",
3079 "optional",
3080 );
3081 self.render_custom_form_field(
3082 layout[4],
3083 buf,
3084 CustomProviderField::ApiKeyEnv,
3085 "API key env",
3086 "optional",
3087 );
3088 }
3089
3090 fn render_custom_form_field(
3091 &self,
3092 area: Rect,
3093 buf: &mut Buffer,
3094 field: CustomProviderField,
3095 label: &str,
3096 placeholder: &str,
3097 ) {
3098 let selected = self.custom_provider_field == field;
3099 let marker = crate::tui::glyphs::selection_marker(selected);
3100 let value = self.custom_form_field_value(field);
3101 let display = if value.is_empty() { placeholder } else { value };
3102 let value_style = if selected {
3103 menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
3104 } else if value.is_empty() {
3105 Style::default().fg(palette::TEXT_MUTED)
3106 } else {
3107 Style::default().fg(palette::TEXT_PRIMARY)
3108 };
3109 let label_style = if selected {
3110 menu_style::selected_row_style_with_fg(palette::WHALE_INFO)
3111 } else {
3112 Style::default().fg(palette::TEXT_MUTED)
3113 };
3114 let mut line = Line::from(vec![
3115 Span::styled(marker, label_style),
3116 Span::styled(" ", label_style),
3117 Span::styled(format!("{label}: "), label_style),
3118 Span::styled(
3119 crate::tui::ui_text::truncate_line_to_width(
3120 display,
3121 usize::from(area.width).saturating_sub(18),
3122 ),
3123 value_style,
3124 ),
3125 ]);
3126 if selected {
3127 line.style = menu_style::selected_row_bg_style();
3128 }
3129 Paragraph::new(line).render(area, buf);
3130 }
3131 }
3132
3133 fn mask_key(input: &str) -> String {
3134 let trimmed = input.trim();
3135 let len = trimmed.chars().count();
3136 if len == 0 {
3137 return String::new();
3138 }
3139 if len <= 4 {
3140 return "*".repeat(len);
3141 }
3142 let visible: String = trimmed
3143 .chars()
3144 .rev()
3145 .take(4)
3146 .collect::<String>()
3147 .chars()
3148 .rev()
3149 .collect();
3150 format!("{}{}", "*".repeat(len - 4), visible)
3151 }
3152
3153 impl ModalView for ProviderPickerView {
3154 fn kind(&self) -> ModalKind {
3155 ModalKind::ProviderPicker
3156 }
3157
3158 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
3159 self
3160 }
3161
3162 fn handle_paste(&mut self, text: &str) -> bool {
3163 match self.stage {
3164 Stage::KeyEntry => {
3165 if self.selected_provider() == ApiProvider::OpenaiCodex {
3166 return true;
3167 }
3168 let sanitized: String = text.chars().filter(|c| !c.is_whitespace()).collect();
3169 if !sanitized.is_empty() {
3170 self.api_key_input.push_str(&sanitized);
3171 self.key_entry_error = None;
3172 }
3173 true
3174 }
3175 Stage::CustomForm => {
3176 let sanitized = text.replace(['\r', '\n', '\t'], " ");
3177 self.custom_form_field_mut().push_str(sanitized.trim());
3178 true
3179 }
3180 Stage::List
3181 | Stage::XaiAuthChoice
3182 | Stage::ExternalConsentChoice
3183 | Stage::ExternalConsentConfirm
3184 | Stage::ModelPick
3185 | Stage::PlanTier
3186 | Stage::StepfunBillingRoute
3187 | Stage::Confirm => false,
3188 }
3189 }
3190
3191 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
3192 match self.stage {
3193 Stage::List => match key.code {
3194 KeyCode::Esc if !self.query.is_empty() => {
3195 self.update_query(String::new());
3196 ViewAction::None
3197 }
3198 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed {
3199 catalog_view: self.view == ProviderListView::Catalog,
3200 selected_provider_id: self
3201 .rows
3202 .get(self.selected_idx)
3203 .map(|row| row.provider_id.clone()),
3204 }),
3205 KeyCode::Up => {
3206 self.move_up();
3207 ViewAction::None
3208 }
3209 KeyCode::Down => {
3210 self.move_down();
3211 ViewAction::None
3212 }
3213 // Row-dependent actions are no-ops when the current filter
3214 // (#3830) hides every row — e.g. a fresh Configured view
3215 // with nothing configured yet shows the empty state and
3216 // `selected_idx` doesn't point at anything on screen.
3217 KeyCode::Enter if self.row_visible(self.selected_idx) => {
3218 let provider = self.selected_provider();
3219 let provider_id = self.selected_provider_id();
3220 if provider == ApiProvider::Custom
3221 && !self.rows[self.selected_idx].is_configured
3222 {
3223 self.enter_custom_form();
3224 ViewAction::None
3225 } else if !self.selected_route_is_valid() {
3226 ViewAction::None
3227 } else if self.selected_has_key() {
3228 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
3229 provider,
3230 provider_id,
3231 })
3232 } else {
3233 self.begin_setup();
3234 ViewAction::None
3235 }
3236 }
3237 KeyCode::Char(c)
3238 if key.modifiers.is_empty()
3239 && self.query.is_empty()
3240 && c.eq_ignore_ascii_case(&'x')
3241 && self.row_visible(self.selected_idx)
3242 && self.rows[self.selected_idx].credential_state
3243 == CredentialState::ExternalConsent =>
3244 {
3245 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
3246 provider: self.selected_provider(),
3247 })
3248 }
3249 KeyCode::Char(c)
3250 if key.modifiers.is_empty()
3251 && c.eq_ignore_ascii_case(&'r')
3252 && self.query.is_empty()
3253 && self.row_visible(self.selected_idx) =>
3254 {
3255 self.begin_setup();
3256 ViewAction::None
3257 }
3258 // Toggle between the configured-only default view and the
3259 // full provider catalog (#3830). Handled before the
3260 // type-ahead arm so `a`/`A` always toggles instead of
3261 // seeking a provider whose name starts with "a".
3262 KeyCode::Char(c)
3263 if key.modifiers.is_empty()
3264 && self.query.is_empty()
3265 && c.eq_ignore_ascii_case(&'a') =>
3266 {
3267 self.toggle_view();
3268 ViewAction::None
3269 }
3270 KeyCode::Char(c)
3271 if key.modifiers.is_empty()
3272 && self.query.is_empty()
3273 && c.eq_ignore_ascii_case(&'c') =>
3274 {
3275 self.enter_custom_form();
3276 ViewAction::None
3277 }
3278 // Jump to the `/model` picker pre-filtered to this provider
3279 // (#3083). Handled before the type-ahead arm so `m`/`M` opens
3280 // models instead of seeking a provider whose name starts with m.
3281 KeyCode::Char(c)
3282 if key.modifiers.is_empty()
3283 && self.query.is_empty()
3284 && c.eq_ignore_ascii_case(&'m')
3285 && self.row_visible(self.selected_idx) =>
3286 {
3287 let provider = self.selected_provider();
3288 let provider_id = self.selected_provider_id();
3289 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
3290 provider,
3291 provider_id,
3292 })
3293 }
3294 KeyCode::Backspace if !self.query.is_empty() => {
3295 let mut query = self.query.clone();
3296 query.pop();
3297 self.update_query(query);
3298 ViewAction::None
3299 }
3300 KeyCode::Char(ch)
3301 if key.modifiers.is_empty()
3302 && !key
3303 .modifiers
3304 .contains(crossterm::event::KeyModifiers::CONTROL) =>
3305 {
3306 let mut query = self.query.clone();
3307 query.push(ch);
3308 self.update_query(query);
3309 ViewAction::None
3310 }
3311 _ => ViewAction::None,
3312 },
3313 Stage::XaiAuthChoice => match key.code {
3314 KeyCode::Esc => {
3315 self.stage = Stage::List;
3316 ViewAction::None
3317 }
3318 KeyCode::Up | KeyCode::Down => {
3319 self.move_xai_auth_choice();
3320 ViewAction::None
3321 }
3322 KeyCode::Char('1') => {
3323 self.xai_auth_choice = XaiAuthChoice::ApiKey;
3324 ViewAction::None
3325 }
3326 KeyCode::Char('2') => {
3327 self.xai_auth_choice = XaiAuthChoice::DeviceOAuth;
3328 ViewAction::None
3329 }
3330 KeyCode::Char(c) if key.modifiers.is_empty() && c.eq_ignore_ascii_case(&'e') => {
3331 self.enter_external_consent_choice();
3332 ViewAction::None
3333 }
3334 KeyCode::Enter => match self.xai_auth_choice {
3335 XaiAuthChoice::ApiKey => {
3336 self.enter_key_entry();
3337 ViewAction::None
3338 }
3339 XaiAuthChoice::DeviceOAuth => {
3340 ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
3341 }
3342 },
3343 _ => ViewAction::None,
3344 },
3345 Stage::KeyEntry => match key.code {
3346 KeyCode::Esc => {
3347 // Back to the route choice when one was made, so Esc undoes
3348 // one wizard step instead of discarding the whole flow.
3349 self.stage = if self.selected_provider() == ApiProvider::Xai {
3350 Stage::XaiAuthChoice
3351 } else if self.pending_base_url.is_some() {
3352 Stage::StepfunBillingRoute
3353 } else {
3354 Stage::List
3355 };
3356 self.api_key_input.clear();
3357 self.key_entry_error = None;
3358 self.pending_api_key = None;
3359 self.model_options.clear();
3360 self.model_selected_idx = 0;
3361 self.selected_model = None;
3362 ViewAction::None
3363 }
3364 KeyCode::Backspace => {
3365 if self.selected_provider() != ApiProvider::OpenaiCodex {
3366 self.api_key_input.pop();
3367 self.key_entry_error = None;
3368 }
3369 ViewAction::None
3370 }
3371 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3372 if self.selected_provider() != ApiProvider::OpenaiCodex {
3373 self.api_key_input.pop();
3374 self.key_entry_error = None;
3375 }
3376 ViewAction::None
3377 }
3378 KeyCode::Enter => {
3379 if self.selected_provider() == ApiProvider::OpenaiCodex {
3380 self.enter_external_consent_choice();
3381 return ViewAction::None;
3382 }
3383 let key = self.api_key_input.trim().to_string();
3384 if key.is_empty() {
3385 // Stay in key-entry; the user can press Esc to abort.
3386 ViewAction::None
3387 } else {
3388 let provider = self.selected_provider();
3389 let provider_id = self.selected_provider_id();
3390 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
3391 provider,
3392 provider_id,
3393 api_key: key,
3394 base_url: self.pending_base_url.clone(),
3395 })
3396 }
3397 }
3398 KeyCode::Char(c)
3399 if !key.modifiers.intersects(
3400 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
3401 ) =>
3402 {
3403 if self.selected_provider() == ApiProvider::OpenaiCodex {
3404 return ViewAction::None;
3405 }
3406 // Reject ASCII whitespace so a stray space/tab doesn't slip
3407 // into a credential; bracketed paste happens via the input
3408 // path that already trims on submit.
3409 if !c.is_whitespace() {
3410 self.api_key_input.push(c);
3411 self.key_entry_error = None;
3412 }
3413 ViewAction::None
3414 }
3415 _ => ViewAction::None,
3416 },
3417 Stage::ExternalConsentChoice => match key.code {
3418 KeyCode::Esc => {
3419 self.stage = if self.selected_provider() == ApiProvider::Xai {
3420 Stage::XaiAuthChoice
3421 } else {
3422 Stage::KeyEntry
3423 };
3424 ViewAction::None
3425 }
3426 KeyCode::Up => {
3427 self.move_external_consent_choice(-1);
3428 ViewAction::None
3429 }
3430 KeyCode::Down => {
3431 self.move_external_consent_choice(1);
3432 ViewAction::None
3433 }
3434 KeyCode::Char('1') => {
3435 self.external_consent_choice = ExternalConsentChoice::Disabled;
3436 ViewAction::None
3437 }
3438 KeyCode::Char('2') => {
3439 self.external_consent_choice = ExternalConsentChoice::ReadOnly;
3440 ViewAction::None
3441 }
3442 KeyCode::Char('3') => {
3443 self.external_consent_choice = ExternalConsentChoice::ManagedUnavailable;
3444 ViewAction::None
3445 }
3446 KeyCode::Enter => match self.external_consent_choice {
3447 ExternalConsentChoice::Disabled => {
3448 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
3449 provider: self.selected_provider(),
3450 })
3451 }
3452 ExternalConsentChoice::ReadOnly => {
3453 self.stage = Stage::ExternalConsentConfirm;
3454 ViewAction::None
3455 }
3456 ExternalConsentChoice::ManagedUnavailable => ViewAction::None,
3457 },
3458 _ => ViewAction::None,
3459 },
3460 Stage::ExternalConsentConfirm => match key.code {
3461 KeyCode::Esc => {
3462 self.stage = Stage::ExternalConsentChoice;
3463 ViewAction::None
3464 }
3465 KeyCode::Enter => self
3466 .build_external_consent_event()
3467 .map(ViewAction::EmitAndClose)
3468 .unwrap_or(ViewAction::None),
3469 _ => ViewAction::None,
3470 },
3471 Stage::ModelPick => match key.code {
3472 KeyCode::Esc => {
3473 // Back to key entry with the validated key pre-filled so the
3474 // user can retype without losing progress.
3475 self.stage = Stage::KeyEntry;
3476 if let Some(pending) = self.pending_api_key.clone() {
3477 self.api_key_input = pending;
3478 }
3479 self.key_entry_error = None;
3480 ViewAction::None
3481 }
3482 KeyCode::Up => {
3483 self.move_model_selection(-1);
3484 ViewAction::None
3485 }
3486 KeyCode::Down => {
3487 self.move_model_selection(1);
3488 ViewAction::None
3489 }
3490 KeyCode::Enter => {
3491 if self.model_options.is_empty() {
3492 return ViewAction::None;
3493 }
3494 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
3495 if self.selected_kimi_code_k3() {
3496 self.enter_plan_tier();
3497 } else {
3498 self.enter_confirm();
3499 }
3500 ViewAction::None
3501 }
3502 _ => ViewAction::None,
3503 },
3504 Stage::StepfunBillingRoute => match key.code {
3505 KeyCode::Esc => {
3506 self.stage = Stage::List;
3507 self.pending_base_url = None;
3508 ViewAction::None
3509 }
3510 KeyCode::Up | KeyCode::Down => {
3511 self.stepfun_billing_route = match self.stepfun_billing_route {
3512 StepfunBillingRoute::PayAsYouGo => StepfunBillingRoute::StepPlan,
3513 StepfunBillingRoute::StepPlan => StepfunBillingRoute::PayAsYouGo,
3514 };
3515 ViewAction::None
3516 }
3517 KeyCode::Char('1') => {
3518 self.stepfun_billing_route = StepfunBillingRoute::PayAsYouGo;
3519 ViewAction::None
3520 }
3521 KeyCode::Char('2') => {
3522 self.stepfun_billing_route = StepfunBillingRoute::StepPlan;
3523 ViewAction::None
3524 }
3525 KeyCode::Enter => {
3526 self.apply_stepfun_billing_route();
3527 ViewAction::None
3528 }
3529 _ => ViewAction::None,
3530 },
3531 Stage::PlanTier => match key.code {
3532 KeyCode::Esc => {
3533 self.stage = Stage::ModelPick;
3534 ViewAction::None
3535 }
3536 KeyCode::Up | KeyCode::Down => {
3537 self.kimi_code_plan_tier = match self.kimi_code_plan_tier {
3538 KimiCodePlanTier::Safe262k => KimiCodePlanTier::OneMillion,
3539 KimiCodePlanTier::OneMillion => KimiCodePlanTier::Safe262k,
3540 };
3541 ViewAction::None
3542 }
3543 KeyCode::Char('1') => {
3544 self.kimi_code_plan_tier = KimiCodePlanTier::Safe262k;
3545 ViewAction::None
3546 }
3547 KeyCode::Char('2') => {
3548 self.kimi_code_plan_tier = KimiCodePlanTier::OneMillion;
3549 ViewAction::None
3550 }
3551 KeyCode::Enter => {
3552 self.apply_plan_tier();
3553 ViewAction::None
3554 }
3555 _ => ViewAction::None,
3556 },
3557 Stage::Confirm => match key.code {
3558 KeyCode::Esc => {
3559 self.stage = if self.selected_kimi_code_k3() {
3560 Stage::PlanTier
3561 } else {
3562 Stage::ModelPick
3563 };
3564 ViewAction::None
3565 }
3566 KeyCode::Enter => self
3567 .build_setup_confirmed_event()
3568 .map(ViewAction::EmitAndClose)
3569 .unwrap_or(ViewAction::None),
3570 _ => ViewAction::None,
3571 },
3572 Stage::CustomForm => match key.code {
3573 KeyCode::Esc => {
3574 self.stage = Stage::List;
3575 ViewAction::None
3576 }
3577 KeyCode::Tab | KeyCode::Down => {
3578 self.advance_custom_field();
3579 ViewAction::None
3580 }
3581 KeyCode::BackTab | KeyCode::Up => {
3582 self.retreat_custom_field();
3583 ViewAction::None
3584 }
3585 KeyCode::Backspace => {
3586 self.custom_form_field_mut().pop();
3587 ViewAction::None
3588 }
3589 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3590 self.custom_form_field_mut().pop();
3591 ViewAction::None
3592 }
3593 KeyCode::Enter if self.custom_provider_field != CustomProviderField::ApiKeyEnv => {
3594 self.advance_custom_field();
3595 ViewAction::None
3596 }
3597 KeyCode::Enter => self
3598 .build_custom_provider_event()
3599 .map(ViewAction::EmitAndClose)
3600 .unwrap_or(ViewAction::None),
3601 KeyCode::Char(c)
3602 if !key
3603 .modifiers
3604 .contains(crossterm::event::KeyModifiers::CONTROL) =>
3605 {
3606 self.custom_form_field_mut().push(c);
3607 ViewAction::None
3608 }
3609 _ => ViewAction::None,
3610 },
3611 }
3612 }
3613
3614 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
3615 match self.stage {
3616 Stage::List => match mouse.kind {
3617 MouseEventKind::ScrollUp => self.move_up(),
3618 MouseEventKind::ScrollDown => self.move_down(),
3619 _ => {}
3620 },
3621 Stage::ModelPick => match mouse.kind {
3622 MouseEventKind::ScrollUp => self.move_model_selection(-1),
3623 MouseEventKind::ScrollDown => self.move_model_selection(1),
3624 _ => {}
3625 },
3626 Stage::PlanTier
3627 | Stage::StepfunBillingRoute
3628 | Stage::XaiAuthChoice
3629 | Stage::KeyEntry
3630 | Stage::ExternalConsentChoice
3631 | Stage::ExternalConsentConfirm
3632 | Stage::Confirm
3633 | Stage::CustomForm => {}
3634 }
3635 ViewAction::None
3636 }
3637
3638 fn render(&self, area: Rect, buf: &mut Buffer) {
3639 let preferred_height = match self.stage {
3640 Stage::List => (self.rows.len() as u16).saturating_add(2),
3641 Stage::XaiAuthChoice => 12,
3642 // Key/OAuth help is intentionally multi-line and wraps at narrow
3643 // widths. One shared height keeps every provider's final guidance
3644 // visible instead of special-casing whichever route clipped last.
3645 Stage::KeyEntry => 14,
3646 Stage::ExternalConsentChoice => 12,
3647 Stage::ExternalConsentConfirm => 13,
3648 Stage::ModelPick => 12,
3649 Stage::PlanTier => 10,
3650 Stage::StepfunBillingRoute => 11,
3651 Stage::Confirm => 10,
3652 Stage::CustomForm => 12,
3653 };
3654 let popup_area = centered_modal_area(area, 120, preferred_height, 64, 8);
3655
3656 render_modal_surface(area, popup_area, buf);
3657
3658 match self.stage {
3659 Stage::List => self.render_list(popup_area, buf),
3660 Stage::XaiAuthChoice => self.render_xai_auth_choice(popup_area, buf),
3661 Stage::KeyEntry => self.render_key_entry(popup_area, buf),
3662 Stage::ExternalConsentChoice => self.render_external_consent_choice(popup_area, buf),
3663 Stage::ExternalConsentConfirm => self.render_external_consent_confirm(popup_area, buf),
3664 Stage::ModelPick => self.render_model_pick(popup_area, buf),
3665 Stage::PlanTier => self.render_plan_tier(popup_area, buf),
3666 Stage::StepfunBillingRoute => self.render_stepfun_billing_route(popup_area, buf),
3667 Stage::Confirm => self.render_confirm(popup_area, buf),
3668 Stage::CustomForm => self.render_custom_form(popup_area, buf),
3669 }
3670 }
3671 }
3672
3673 fn non_empty_string(value: &str) -> Option<String> {
3674 let trimmed = value.trim();
3675 (!trimmed.is_empty()).then(|| trimmed.to_string())
3676 }
3677
3678 fn custom_provider_dashboard_rows(
3679 active: ApiProvider,
3680 config: &Config,
3681 runtime_status: Option<&ProviderRuntimeStatus>,
3682 ) -> Vec<ProviderDashboardRow> {
3683 let Some(providers) = config.providers.as_ref() else {
3684 return Vec::new();
3685 };
3686 let mut ids: Vec<_> = providers.custom.keys().cloned().collect();
3687 ids.sort_by_key(|id| id.to_ascii_lowercase());
3688 ids.into_iter()
3689 .filter(|id| {
3690 providers
3691 .custom_provider_config(id)
3692 .is_some_and(|entry| entry.is_openai_compatible_custom())
3693 })
3694 .map(|id| {
3695 ProviderDashboardRow::from_custom_config_with_runtime_status(
3696 &id,
3697 active,
3698 config,
3699 runtime_status,
3700 )
3701 })
3702 .collect()
3703 }
3704
3705 #[cfg(test)]
3706 mod tests {
3707 use super::*;
3708 use crate::test_support::EnvVarGuard;
3709 use crossterm::event::{KeyEvent, KeyModifiers};
3710
3711 // Environment-mutating tests in this module hold the process-wide
3712 // `lock_test_env()` (via `crate::test_support`), the same barrier every
3713 // other module's env tests use. A module-private mutex cannot serialize
3714 // against the rest of the suite, so sibling tests raced on shared
3715 // provider env vars (EXAMPLE_API_KEY, OPENROUTER_API_KEY, ...) and a panic
3716 // while holding it cascaded PoisonError failures into unrelated tests.
3717
3718 fn key(code: KeyCode) -> KeyEvent {
3719 KeyEvent::new(code, KeyModifiers::NONE)
3720 }
3721
3722 fn move_to_provider(picker: &mut ProviderPickerView, provider: ApiProvider) {
3723 // The target may be hidden by the default configured-only view
3724 // (#3830); switch to the full catalog so navigation can still reach
3725 // it, matching what a user pressing `A` would do.
3726 if let Some(idx) = picker.rows.iter().position(|row| row.provider == provider)
3727 && !picker.row_visible(idx)
3728 {
3729 picker.toggle_view();
3730 }
3731 let max_steps = picker.rows.len();
3732 for _ in 0..max_steps {
3733 if picker.selected_provider() == provider {
3734 return;
3735 }
3736 picker.handle_key(key(KeyCode::Down));
3737 }
3738 panic!("provider {provider:?} not found in picker");
3739 }
3740
3741 fn render_text(picker: &ProviderPickerView, width: u16, height: u16) -> String {
3742 let area = Rect::new(0, 0, width, height);
3743 let mut buf = Buffer::empty(area);
3744 picker.render(area, &mut buf);
3745 (0..height)
3746 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
3747 .collect::<Vec<_>>()
3748 .join("\n")
3749 }
3750
3751 #[test]
3752 fn provider_picker_semantically_truncates_dense_rows_at_narrow_width() {
3753 let config = Config::default();
3754 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3755 picker.toggle_view();
3756
3757 let text = render_text(&picker, 64, 16);
3758 assert!(text.contains('…'), "{text}");
3759 for (idx, line) in text.lines().enumerate() {
3760 assert!(
3761 crate::tui::ui_text::text_display_width(line) <= 64,
3762 "line {idx} overflows: {line:?}"
3763 );
3764 }
3765 }
3766
3767 #[test]
3768 fn type_ahead_jumps_to_provider_by_first_letter() {
3769 let config = Config::default();
3770 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3771 // Z.ai isn't configured, so it's hidden by the default view (#3830);
3772 // browse the full catalog like a user pressing `A` would.
3773 picker.toggle_view();
3774 // Search for "zai" — unique enough to match only Z.ai.
3775 for c in "zai".chars() {
3776 picker.handle_key(key(KeyCode::Char(c)));
3777 }
3778 assert_eq!(picker.query, "zai");
3779 let filtered = picker.filtered_rows();
3780 assert!(!filtered.is_empty(), "search for 'zai' must match Z.ai");
3781 assert!(
3782 filtered
3783 .iter()
3784 .any(|(_, row)| row.provider == ApiProvider::Zai),
3785 "Z.ai must be in filtered results: {:?}",
3786 filtered
3787 .iter()
3788 .map(|(_, r)| &r.display_name)
3789 .collect::<Vec<_>>()
3790 );
3791 assert_eq!(picker.selected_provider(), ApiProvider::Zai);
3792 }
3793
3794 #[test]
3795 fn compact_base_url_strips_scheme_and_caps_length() {
3796 // Short URLs pass through unchanged (scheme + trailing slash stripped).
3797 assert_eq!(
3798 compact_base_url("https://api.deepseek.com/"),
3799 "api.deepseek.com"
3800 );
3801 assert_eq!(
3802 compact_base_url("http://localhost:9000/v1"),
3803 "localhost:9000/v1"
3804 );
3805 // A long URL is capped so it can't dominate the hint row.
3806 let long = compact_base_url("https://api-us-west-2.example-region.company.com/v1/openai");
3807 assert!(long.ends_with("..."), "expected an ellipsis, got {long:?}");
3808 assert!(
3809 long.chars().count() <= 24,
3810 "capped to 24 cols, got {long:?}"
3811 );
3812 }
3813
3814 #[test]
3815 fn mouse_scroll_moves_selection_in_list_stage() {
3816 let config = Config::default();
3817 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3818 // Scroll across the full catalog (#3830), not just the configured
3819 // subset, which would only contain the active provider here.
3820 picker.toggle_view();
3821 let before = picker.selected_idx;
3822 picker.handle_mouse(MouseEvent {
3823 kind: MouseEventKind::ScrollDown,
3824 column: 0,
3825 row: 0,
3826 modifiers: KeyModifiers::NONE,
3827 });
3828 assert_ne!(
3829 picker.selected_idx, before,
3830 "scroll down should advance the selection"
3831 );
3832 }
3833
3834 #[test]
3835 fn picker_lists_all_providers() {
3836 let config = Config::default();
3837 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3838 let names: Vec<_> = picker
3839 .rows
3840 .iter()
3841 .map(|row| row.display_name.as_str())
3842 .collect();
3843
3844 // Catalog surface: one identity per vendor (not dual-wire / plan kinds).
3845 assert_eq!(names.len(), ApiProvider::catalog().len());
3846 assert!(names.contains(&"DeepSeek"));
3847 assert!(names.contains(&"Alibaba Cloud Model Studio"));
3848 // Dialect is wire config — no second MiniMax / Model Studio rows.
3849 assert_eq!(
3850 names
3851 .iter()
3852 .filter(|name| name.contains("Alibaba Cloud Model Studio"))
3853 .count(),
3854 1
3855 );
3856 assert_eq!(names.iter().filter(|name| **name == "MiniMax").count(), 1);
3857 assert_eq!(names.iter().filter(|name| **name == "DeepSeek").count(), 1);
3858
3859 // Providers are presented in neutral case-insensitive alphabetical
3860 // order by display name (#3076), not `ApiProvider::all()` order.
3861 let mut expected = names.clone();
3862 expected.sort_by_key(|name| name.to_ascii_lowercase());
3863 assert_eq!(
3864 names, expected,
3865 "provider picker must list providers in case-insensitive alphabetical order"
3866 );
3867 // DeepSeek is no longer hard-coded first.
3868 assert_ne!(names.first(), Some(&"DeepSeek"));
3869 }
3870
3871 #[test]
3872 fn default_view_shows_only_configured_providers() {
3873 // #3830: with nothing but the active provider set up, the default
3874 // list view excludes the unconfigured catalog noise — even though
3875 // `rows` (the underlying data) still has every provider, per
3876 // `picker_lists_all_providers` above. Doesn't assert an exact count:
3877 // `OpenaiCodex` reads a real OAuth file from disk in
3878 // `has_api_key_for`, so it's legitimately "configured" on a machine
3879 // with a prior Codex login and must not make this test host-dependent.
3880 let config = Config::default();
3881 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3882
3883 assert_eq!(picker.view, ProviderListView::Configured);
3884 let visible: Vec<ApiProvider> = picker
3885 .filtered_rows()
3886 .iter()
3887 .map(|(_, row)| row.provider)
3888 .collect();
3889 assert!(visible.contains(&ApiProvider::Deepseek), "{visible:?}");
3890 assert!(
3891 !visible.contains(&ApiProvider::Custom),
3892 "the unused custom-provider placeholder slot isn't \"configured\": {visible:?}"
3893 );
3894 for unconfigured in [
3895 ApiProvider::Zai,
3896 ApiProvider::Openrouter,
3897 ApiProvider::Novita,
3898 ApiProvider::Ollama,
3899 ] {
3900 assert!(
3901 !visible.contains(&unconfigured),
3902 "{unconfigured:?} has no credentials and isn't active: {visible:?}"
3903 );
3904 }
3905 assert!(
3906 picker.rows.len() > visible.len(),
3907 "underlying data keeps every provider"
3908 );
3909 }
3910
3911 #[test]
3912 fn explicit_provider_config_marks_provider_configured_without_active_or_key() {
3913 // #3830: a non-default `[providers.<name>]` entry (here just a base
3914 // URL override, no key) counts as "configured" even though the
3915 // provider is neither active nor has working credentials.
3916 let config = Config {
3917 providers: Some(crate::config::ProvidersConfig {
3918 openrouter: crate::config::ProviderConfig {
3919 base_url: Some("https://custom.openrouter.example/v1".to_string()),
3920 ..Default::default()
3921 },
3922 ..Default::default()
3923 }),
3924 ..Config::default()
3925 };
3926 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3927 let row = picker
3928 .rows
3929 .iter()
3930 .find(|row| row.provider == ApiProvider::Openrouter)
3931 .expect("openrouter row");
3932 assert!(row.is_configured);
3933 assert!(!row.has_key, "explicit config doesn't imply a working key");
3934 }
3935
3936 #[test]
3937 fn empty_provider_headers_do_not_mark_provider_configured() {
3938 let _env = crate::test_support::lock_test_env();
3939 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
3940 let config = Config {
3941 providers: Some(crate::config::ProvidersConfig {
3942 anthropic: crate::config::ProviderConfig {
3943 http_headers: Some(std::collections::HashMap::new()),
3944 ..Default::default()
3945 },
3946 ..Default::default()
3947 }),
3948 ..Config::default()
3949 };
3950 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3951 let anthropic = picker
3952 .rows
3953 .iter()
3954 .find(|row| row.provider == ApiProvider::Anthropic)
3955 .expect("anthropic row");
3956
3957 assert!(
3958 !anthropic.is_configured,
3959 "an empty deserialized header table is default state, not setup"
3960 );
3961 }
3962
3963 #[test]
3964 fn non_empty_provider_headers_mark_provider_configured() {
3965 let config = Config {
3966 providers: Some(crate::config::ProvidersConfig {
3967 anthropic: crate::config::ProviderConfig {
3968 http_headers: Some(std::collections::HashMap::from([(
3969 "X-Route".to_string(),
3970 "custom".to_string(),
3971 )])),
3972 ..Default::default()
3973 },
3974 ..Default::default()
3975 }),
3976 ..Config::default()
3977 };
3978 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
3979 let anthropic = picker
3980 .rows
3981 .iter()
3982 .find(|row| row.provider == ApiProvider::Anthropic)
3983 .expect("anthropic row");
3984
3985 assert!(
3986 anthropic.is_configured,
3987 "a user-authored header is meaningful explicit provider setup"
3988 );
3989 }
3990
3991 #[test]
3992 fn blank_provider_header_entries_do_not_mark_provider_configured() {
3993 let _env = crate::test_support::lock_test_env();
3994 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
3995 let config = Config {
3996 providers: Some(crate::config::ProvidersConfig {
3997 anthropic: crate::config::ProviderConfig {
3998 http_headers: Some(std::collections::HashMap::from([
3999 (" ".to_string(), "value".to_string()),
4000 ("X-Blank".to_string(), " ".to_string()),
4001 ])),
4002 ..Default::default()
4003 },
4004 ..Default::default()
4005 }),
4006 ..Config::default()
4007 };
4008 assert!(!crate::config::provider_is_configured_for_active(
4009 &config,
4010 ApiProvider::Anthropic,
4011 ApiProvider::Deepseek,
4012 ));
4013 }
4014
4015 #[test]
4016 fn self_hosted_provider_not_auto_configured_without_explicit_setup() {
4017 // #3830: `has_api_key_for` always reports `true` for self-hosted
4018 // providers (no auth required to route to them) — that must not, on
4019 // its own, make Ollama/Sglang/Vllm show up in the default
4020 // configured-only view for every user regardless of setup.
4021 let config = Config::default();
4022 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
4023 let ollama = picker
4024 .rows
4025 .iter()
4026 .find(|row| row.provider == ApiProvider::Ollama)
4027 .expect("ollama row");
4028 assert!(
4029 ollama.has_key,
4030 "self-hosted providers report has_key unconditionally"
4031 );
4032 assert!(
4033 !ollama.is_configured,
4034 "but that alone must not mark them configured"
4035 );
4036
4037 // Active self-hosted provider still counts as configured.
4038 let active_picker = ProviderPickerView::new(ApiProvider::Ollama, &config);
4039 let active_ollama = active_picker
4040 .rows
4041 .iter()
4042 .find(|row| row.provider == ApiProvider::Ollama)
4043 .expect("ollama row");
4044 assert!(active_ollama.is_configured);
4045 }
4046
4047 #[test]
4048 fn toggle_view_reveals_full_catalog_and_back() {
4049 let config = Config::default();
4050 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
4051 let configured_count = picker.filtered_rows().len();
4052 assert_eq!(picker.view, ProviderListView::Configured);
4053
4054 let action = picker.handle_key(key(KeyCode::Char('a')));
4055 assert!(matches!(action, ViewAction::None));
4056 assert_eq!(picker.view, ProviderListView::Catalog);
4057 assert_eq!(picker.filtered_rows().len(), picker.rows.len());
4058 assert!(picker.filtered_rows().len() > configured_count);
4059
4060 picker.handle_key(key(KeyCode::Char('A')));
4061 assert_eq!(picker.view, ProviderListView::Configured);
4062 assert_eq!(picker.filtered_rows().len(), configured_count);
4063 }
4064
4065 #[test]
4066 fn key_entry_hint_uses_metadata_env_vars() {
4067 assert_eq!(
4068 ProviderPickerView::env_var_for(ApiProvider::NvidiaNim),
4069 "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY / DEEPSEEK_API_KEY"
4070 );
4071 }
4072
4073 #[test]
4074 fn key_entry_hint_includes_provider_credential_url() {
4075 let config = Config::default();
4076 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
4077 move_to_provider(&mut picker, ApiProvider::NvidiaNim);
4078 picker.handle_key(key(KeyCode::Enter));
4079
4080 let rendered = render_text(&picker, 120, 20);
4081
4082 assert!(rendered.contains("NVIDIA_API_KEY / NVIDIA_NIM_API_KEY / DEEPSEEK_API_KEY"));
4083 assert!(rendered.contains("https://build.nvidia.com/settings/api-keys"));
4084 }
4085
4086 #[test]
4087 fn zai_key_entry_wraps_long_environment_guidance_without_hiding_credentials_url() {
4088 let config = Config::default();
4089 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
4090 move_to_provider(&mut picker, ApiProvider::Zai);
4091 picker.handle_key(key(KeyCode::Enter));
4092
4093 // Reproduce the width from the dogfood screenshot: the old renderer
4094 // allocated one row per logical line, so the long env-var sentence
4095 // clipped and displaced the credentials URL.
4096 let rendered = render_text(&picker, 100, 20);
4097
4098 for name in [
4099 "ZAI_API_KEY",
4100 "Z_AI_API_KEY",
4101 "ZHIPU_API_KEY",
4102 "GLM_API_KEY",
4103 ] {
4104 assert!(rendered.contains(name), "missing {name}:\n{rendered}");
4105 }
4106 assert!(rendered.contains("re-open /provider."), "{rendered}");
4107 assert!(
4108 rendered.contains("Credentials: https://z.ai/model-api"),
4109 "{rendered}"
4110 );
4111 }
4112
4113 #[test]
4114 fn kimi_key_entry_uses_the_direct_api_key_console_without_oauth_copy() {
4115 let config = Config::default();
4116 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
4117 move_to_provider(&mut picker, ApiProvider::Moonshot);
4118 picker.handle_key(key(KeyCode::Enter));
4119
4120 let rendered = render_text(&picker, 120, 20);
4121
4122 assert!(rendered.contains("https://platform.kimi.ai/console/api-keys"));
4123 assert!(rendered.contains("paste key here"));
4124 assert!(!rendered.contains("OAuth"));
4125 assert!(!rendered.contains("device login"));
4126 }
4127
4128 #[test]
4129 fn kimi_code_plan_key_entry_uses_membership_route_guidance() {
4130 let config = Config {
4131 provider: Some("moonshot".to_string()),
4132 providers: Some(crate::config::ProvidersConfig {
4133 moonshot: crate::config::ProviderConfig {
4134 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
4135 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
4136 ..Default::default()
4137 },
4138 ..Default::default()
4139 }),
4140 ..Default::default()
4141 };
4142 let mut picker = ProviderPickerView::new(ApiProvider::Moonshot, &config);
4143 assert_eq!(picker.selected_provider(), ApiProvider::Moonshot);
4144 picker.handle_key(key(KeyCode::Enter));
4145
4146 let rendered = render_text(&picker, 120, 24);
4147
4148 assert!(rendered.contains("https://www.kimi.com/code/console"));
4149 assert!(rendered.contains("api.kimi.com/coding/v1"));
4150 assert!(rendered.contains("does not import Kimi CLI credentials"));
4151 assert!(!rendered.contains("https://platform.kimi.ai/console/api-keys"));
4152 assert!(!rendered.contains("OAuth"));
4153 }
4154
4155 #[test]
4156 fn recovery_picker_keeps_active_route_and_esc_makes_no_change() {
4157 let config = Config {
4158 provider: Some("moonshot".to_string()),
4159 providers: Some(crate::config::ProvidersConfig {
4160 moonshot: crate::config::ProviderConfig {
4161 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
4162 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
4163 ..Default::default()
4164 },
4165 ..Default::default()
4166 }),
4167 ..Default::default()
4168 };
4169 let mut picker = ProviderPickerView::new(ApiProvider::Moonshot, &config);
4170
4171 assert_eq!(picker.stage, Stage::List);
4172 assert_eq!(picker.selected_provider(), ApiProvider::Moonshot);
4173 assert!(matches!(
4174 picker.handle_key(key(KeyCode::Esc)),
4175 ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
4176 ));
4177 assert_eq!(config.provider.as_deref(), Some("moonshot"));
4178 assert_eq!(
4179 config
4180 .provider_config_for(ApiProvider::Moonshot)
4181 .and_then(|entry| entry.base_url.as_deref()),
4182 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
4183 );
4184 }
4185
4186 #[test]
4187 fn recovery_model_pick_restores_exact_kimi_code_k3_without_catalog_leakage() {
4188 let mut config = Config {
4189 provider: Some("moonshot".to_string()),
4190 providers: Some(crate::config::ProvidersConfig {
4191 moonshot: crate::config::ProviderConfig {
4192 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
4193 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
4194 ..Default::default()
4195 },
4196 ..Default::default()
4197 }),
4198 ..Default::default()
4199 };
4200 let picker = ProviderPickerView::new_for_model_pick_after_validation(
4201 ApiProvider::Moonshot,
4202 ApiProvider::Moonshot,
4203 &config,
4204 None,
4205 "validated-key".to_string(),
4206 None,
4207 )
4208 .expect("Kimi route row");
4209
4210 assert_eq!(picker.selected_model.as_deref(), Some("k3"));
4211 assert_eq!(
4212 picker
4213 .model_options
4214 .iter()
4215 .filter(|model| model.eq_ignore_ascii_case("k3"))
4216 .count(),
4217 1,
4218 "the current wire model must be appended once, case-insensitively"
4219 );
4220
4221 config
4222 .providers
4223 .as_mut()
4224 .expect("providers")
4225 .moonshot
4226 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
4227 let generic = ProviderPickerView::new_for_model_pick_after_validation(
4228 ApiProvider::Moonshot,
4229 ApiProvider::Moonshot,
4230 &config,
4231 None,
4232 "validated-key".to_string(),
4233 None,
4234 )
4235 .expect("generic Moonshot row");
4236 assert!(
4237 !generic
4238 .model_options
4239 .iter()
4240 .any(|model| model.eq_ignore_ascii_case("k3")),
4241 "bare K3 stays route-local and must not be added to generic Moonshot"
4242 );
4243 }
4244
4245 #[test]
4246 fn setup_provider_key_entry_matrix_keeps_hosted_codex_and_local_hints_distinct() {
4247 let _guard = crate::test_support::lock_test_env();
4248 let tmp = tempfile::TempDir::new().expect("tempdir");
4249 let codewhale_home = tmp.path().join(".codewhale");
4250 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
4251 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
4252 let _codewhale_home =
4253 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
4254 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
4255 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
4256 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
4257 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
4258 let config = Config::default();
4259
4260 let hosted = ProviderPickerView::new_for_setup(
4261 ApiProvider::Openai,
4262 Some(ApiProvider::Deepseek),
4263 &config,
4264 None,
4265 );
4266 assert_eq!(hosted.stage, Stage::KeyEntry);
4267 assert_eq!(hosted.selected_provider(), ApiProvider::Deepseek);
4268 let hosted_text = render_text(&hosted, 120, 20);
4269 assert!(hosted_text.contains("DEEPSEEK_API_KEY"), "{hosted_text}");
4270 assert!(
4271 hosted_text.contains("Credentials: https://platform.deepseek.com/api_keys"),
4272 "{hosted_text}"
4273 );
4274 assert!(!hosted_text.contains("OAuth login"), "{hosted_text}");
4275
4276 let codex = ProviderPickerView::new_for_setup(
4277 ApiProvider::Deepseek,
4278 Some(ApiProvider::OpenaiCodex),
4279 &config,
4280 None,
4281 );
4282 assert_eq!(codex.stage, Stage::KeyEntry);
4283 assert_eq!(codex.selected_provider(), ApiProvider::OpenaiCodex);
4284 let codex_text = render_text(&codex, 120, 20);
4285 assert!(codex_text.contains("OAuth login"), "{codex_text}");
4286 assert!(
4287 codex_text.contains("OPENAI_CODEX_ACCESS_TOKEN"),
4288 "{codex_text}"
4289 );
4290 assert!(codex_text.contains("external-consent"), "{codex_text}");
4291 assert!(!codex_text.contains("Credentials:"), "{codex_text}");
4292 assert!(!codex_text.contains("(paste key here)"), "{codex_text}");
4293
4294 let local = ProviderPickerView::new_for_setup(
4295 ApiProvider::Deepseek,
4296 Some(ApiProvider::Ollama),
4297 &config,
4298 None,
4299 );
4300 assert_eq!(local.stage, Stage::List);
4301 assert_eq!(local.selected_provider(), ApiProvider::Ollama);
4302 let local_text = render_text(&local, 120, 20);
4303 assert!(!local_text.contains("Credentials:"), "{local_text}");
4304
4305 let mut custom = std::collections::HashMap::new();
4306 custom.insert(
4307 "my_thing".to_string(),
4308 crate::config::ProviderConfig {
4309 kind: Some("openai-compatible".to_string()),
4310 base_url: Some("https://api.example.com/v1".to_string()),
4311 model: Some("vendor/custom-model-v1".to_string()),
4312 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
4313 ..Default::default()
4314 },
4315 );
4316 let _custom_key = crate::test_support::EnvVarGuard::remove("EXAMPLE_API_KEY");
4317 let custom_config = Config {
4318 provider: Some("my_thing".to_string()),
4319 providers: Some(crate::config::ProvidersConfig {
4320 custom,
4321 ..Default::default()
4322 }),
4323 ..Config::default()
4324 };
4325 let custom_picker =
4326 ProviderPickerView::new_for_setup(ApiProvider::Custom, None, &custom_config, None);
4327 let custom_row = &custom_picker.rows[custom_picker.selected_idx];
4328 assert_eq!(custom_row.provider, ApiProvider::Custom);
4329 assert_eq!(custom_row.provider_id, "my_thing");
4330 assert!(
4331 custom_row
4332 .messages
4333 .iter()
4334 .any(|message| message.contains("EXAMPLE_API_KEY")),
4335 "custom setup row should name its configured auth env var: {:?}",
4336 custom_row.messages
4337 );
4338 let custom_text = render_text(&custom_picker, 120, 20);
4339 assert!(custom_text.contains("my_thing"), "{custom_text}");
4340 assert!(custom_text.contains("EXAMPLE_API_KEY"), "{custom_text}");
4341 assert!(!custom_text.contains("Credentials:"), "{custom_text}");
4342 }
4343
4344 #[test]
4345 fn provider_dashboard_row_models_local_readiness_without_rendering() {
4346 let config = Config::default();
4347 let row =
4348 ProviderDashboardRow::from_config(ApiProvider::Ollama, ApiProvider::Ollama, &config);
4349
4350 assert_eq!(row.provider_id, "ollama");
4351 assert_eq!(row.auth_status, ProviderAuthStatus::Local);
4352 assert_eq!(row.readiness, ResolvedProviderReadiness::LocalUnchecked);
4353 assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
4354 assert_eq!(row.usage_meter, "cost: local");
4355 assert!(row.base_url.contains("localhost:11434"));
4356 assert!(row.is_active);
4357 }
4358
4359 #[test]
4360 fn deepseek_cn_row_uses_shared_readiness_and_strict_model_validation() {
4361 let _lock = crate::test_support::lock_test_env();
4362 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
4363 let missing = Config {
4364 provider: Some("deepseek-cn".to_string()),
4365 ..Default::default()
4366 };
4367 let missing_row = ProviderDashboardRow::from_config(
4368 ApiProvider::DeepseekCN,
4369 ApiProvider::DeepseekCN,
4370 &missing,
4371 );
4372 assert_eq!(missing_row.readiness, ResolvedProviderReadiness::MissingKey);
4373 assert_ne!(missing_row.auth_status, ProviderAuthStatus::Legacy);
4374
4375 let configured = Config {
4376 provider: Some("deepseek-cn".to_string()),
4377 providers: Some(crate::config::ProvidersConfig {
4378 deepseek_cn: crate::config::ProviderConfig {
4379 api_key: Some("deepseek-cn-test-key".to_string()),
4380 model: Some("deepseek-v4-pro".to_string()),
4381 ..Default::default()
4382 },
4383 ..Default::default()
4384 }),
4385 ..Default::default()
4386 };
4387 let configured_row = ProviderDashboardRow::from_config(
4388 ApiProvider::DeepseekCN,
4389 ApiProvider::DeepseekCN,
4390 &configured,
4391 );
4392 assert_eq!(
4393 configured_row.readiness,
4394 ResolvedProviderReadiness::SavedUnchecked
4395 );
4396
4397 let mut invalid = configured;
4398 invalid
4399 .providers
4400 .as_mut()
4401 .expect("providers")
4402 .deepseek_cn
4403 .model = Some("anthropic/claude-foreign".to_string());
4404 let invalid_row = ProviderDashboardRow::from_config(
4405 ApiProvider::DeepseekCN,
4406 ApiProvider::DeepseekCN,
4407 &invalid,
4408 );
4409 assert_eq!(
4410 invalid_row.readiness,
4411 ResolvedProviderReadiness::InvalidRoute
4412 );
4413 }
4414
4415 #[test]
4416 fn provider_health_requires_observed_success_and_keeps_failure_reason() {
4417 let config = Config {
4418 api_key: Some("saved-key".to_string()),
4419 ..Config::default()
4420 };
4421 let unchecked = ProviderPickerView::new(ApiProvider::Deepseek, &config);
4422 let row = unchecked
4423 .rows
4424 .iter()
4425 .find(|row| row.provider == ApiProvider::Deepseek)
4426 .expect("DeepSeek row");
4427 assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
4428
4429 let mut health = ProviderReadinessSnapshot::default();
4430 health.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro");
4431 let ready =
4432 ProviderPickerView::new(ApiProvider::Deepseek, &config).with_provider_health(&health);
4433 assert_eq!(
4434 ready
4435 .rows
4436 .iter()
4437 .find(|row| row.provider == ApiProvider::Deepseek)
4438 .unwrap()
4439 .readiness,
4440 ResolvedProviderReadiness::Ready
4441 );
4442
4443 health.record_failure_message(
4444 &config,
4445 ApiProvider::Deepseek,
4446 "deepseek-v4-pro",
4447 crate::error_taxonomy::ErrorCategory::Authentication,
4448 "credential rejected",
4449 );
4450 let failed =
4451 ProviderPickerView::new(ApiProvider::Deepseek, &config).with_provider_health(&health);
4452 let row = failed
4453 .rows
4454 .iter()
4455 .find(|row| row.provider == ApiProvider::Deepseek)
4456 .unwrap();
4457 assert!(row.readiness.label().contains("last check failed"));
4458 assert!(
4459 row.messages
4460 .iter()
4461 .any(|message| message == "credential rejected")
4462 );
4463 }
4464
4465 #[test]
4466 fn openai_codex_row_is_experimental_and_tagged_in_hint() {
4467 let config = Config::default();
4468 let row = ProviderDashboardRow::from_config(
4469 ApiProvider::OpenaiCodex,
4470 ApiProvider::Deepseek,
4471 &config,
4472 );
4473
4474 // #2984: maturity is a separate axis from auth/readiness.
4475 assert_eq!(row.maturity, ProviderMaturity::Experimental);
4476 assert!(
4477 row.compact_hint().contains("experimental"),
4478 "experimental maturity must surface in the hint, got {:?}",
4479 row.compact_hint()
4480 );
4481 }
4482
4483 #[test]
4484 fn mainstream_provider_is_supported_without_experimental_tag() {
4485 let config = Config::default();
4486 let row = ProviderDashboardRow::from_config(
4487 ApiProvider::Deepseek,
4488 ApiProvider::Deepseek,
4489 &config,
4490 );
4491
4492 // #2984: supported integrations stay noise-free (no tag).
4493 assert_eq!(row.maturity, ProviderMaturity::Supported);
4494 assert!(
4495 !row.compact_hint().contains("experimental"),
4496 "supported providers must omit the experimental tag, got {:?}",
4497 row.compact_hint()
4498 );
4499 }
4500
4501 #[test]
4502 fn provider_dashboard_row_surfaces_glm_reasoning_controls() {
4503 let config = Config {
4504 reasoning_effort: Some("max".to_string()),
4505 providers: Some(crate::config::ProvidersConfig {
4506 zai: crate::config::ProviderConfig {
4507 api_key: Some("zai-key".to_string()),
4508 model: Some("GLM-5.2".to_string()),
4509 ..Default::default()
4510 },
4511 ..Default::default()
4512 }),
4513 ..Config::default()
4514 };
4515 let row = ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Zai, &config);
4516
4517 assert_eq!(row.default_route.wire_model, "GLM-5.2");
4518 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
4519 assert_eq!(
4520 row.reasoning.controls,
4521 vec!["high".to_string(), "max".to_string()]
4522 );
4523 assert_eq!(
4524 row.reasoning.stream_visibility,
4525 ProviderReasoningStreamVisibility::StructuredThinking
4526 );
4527 assert_eq!(row.reasoning.selected_control.as_deref(), Some("max"));
4528 assert!(row.compact_hint().contains("reasoning:high/max"));
4529 assert!(row.compact_hint().contains("stream:structured"));
4530 }
4531
4532 #[test]
4533 fn provider_dashboard_row_surfaces_modelstudio_structured_thinking() {
4534 let config = Config {
4535 providers: Some(crate::config::ProvidersConfig {
4536 modelstudio_token_plan: crate::config::ProviderConfig {
4537 api_key: Some("modelstudio-key".to_string()),
4538 model: Some("qwen3.8-max".to_string()),
4539 ..Default::default()
4540 },
4541 ..Default::default()
4542 }),
4543 ..Config::default()
4544 };
4545 let row = ProviderDashboardRow::from_config(
4546 ApiProvider::ModelstudioTokenPlan,
4547 ApiProvider::ModelstudioTokenPlan,
4548 &config,
4549 );
4550
4551 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
4552 assert_eq!(
4553 row.reasoning.stream_visibility,
4554 ProviderReasoningStreamVisibility::StructuredThinking
4555 );
4556 assert!(row.compact_hint().contains("stream:structured"));
4557 }
4558
4559 #[test]
4560 fn provider_dashboard_row_surfaces_kimi_code_k3_reasoning_only_on_exact_route() {
4561 let config = Config {
4562 providers: Some(crate::config::ProvidersConfig {
4563 moonshot: crate::config::ProviderConfig {
4564 api_key: Some("kimi-code-key".to_string()),
4565 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
4566 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
4567 ..Default::default()
4568 },
4569 ..Default::default()
4570 }),
4571 ..Config::default()
4572 };
4573 let row = ProviderDashboardRow::from_config(
4574 ApiProvider::Moonshot,
4575 ApiProvider::Moonshot,
4576 &config,
4577 );
4578
4579 assert_eq!(
4580 row.default_route.wire_model,
4581 crate::config::KIMI_CODE_K3_MODEL
4582 );
4583 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
4584 assert_eq!(
4585 row.reasoning.stream_visibility,
4586 ProviderReasoningStreamVisibility::StructuredThinking
4587 );
4588 assert_eq!(
4589 row.reasoning.controls,
4590 vec!["low".to_string(), "high".to_string(), "max".to_string()]
4591 );
4592 assert_eq!(
4593 row.capabilities.context_window,
4594 Some(262_144),
4595 "the picker must show the route-effective K3 baseline, not the generic fallback"
4596 );
4597 assert_eq!(
4598 row.capabilities.context_window_source.as_deref(),
4599 Some("static Kimi Code safe floor"),
4600 "the picker must name the provenance instead of presenting a bare limit as provider fact"
4601 );
4602 assert!(
4603 row.compact_hint()
4604 .contains("ctx:262K(static Kimi Code safe floor)"),
4605 "the compact picker receipt must retain context provenance"
4606 );
4607
4608 let mut direct = config.clone();
4609 direct
4610 .providers
4611 .as_mut()
4612 .expect("providers")
4613 .moonshot
4614 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
4615 let direct_row = ProviderDashboardRow::from_config(
4616 ApiProvider::Moonshot,
4617 ApiProvider::Moonshot,
4618 &direct,
4619 );
4620 assert_ne!(
4621 direct_row.reasoning.support,
4622 ProviderReasoningSupport::Supported,
4623 "generic Moonshot k3 must not inherit Kimi Code's route-owned capability"
4624 );
4625 // The generic model-facts table now carries the same conservative
4626 // number for bare `k3`, so the route-ownership distinction lives in
4627 // provenance: the direct Moonshot row must never claim the Kimi Code
4628 // route-owned floor as its source.
4629 assert_ne!(
4630 direct_row.capabilities.context_window_source.as_deref(),
4631 Some("static Kimi Code safe floor")
4632 );
4633 }
4634
4635 #[test]
4636 fn provider_row_query_matches_default_route_model_and_wire_id() {
4637 // #4141: cross-field search must also match the default route's display
4638 // model name and wire model id, keeping this picker consistent with the
4639 // model picker (`model_row_matches_query`). Z.ai's provider key,
4640 // display name, kind, and base URL contain no "glm", so a "glm" match
4641 // can only come from the route's model/wire fields.
4642 let config = Config {
4643 providers: Some(crate::config::ProvidersConfig {
4644 zai: crate::config::ProviderConfig {
4645 api_key: Some("zai-key".to_string()),
4646 model: Some("GLM-5.2".to_string()),
4647 ..Default::default()
4648 },
4649 ..Default::default()
4650 }),
4651 ..Config::default()
4652 };
4653 let row = ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Zai, &config);
4654 assert_eq!(row.default_route.wire_model, "GLM-5.2");
4655
4656 // Wire model id + display model name, case-insensitively.
4657 assert!(row.matches_query("glm-5.2"));
4658 assert!(row.matches_query("GLM"));
4659 // Provider name still matches, and an unrelated token still does not.
4660 assert!(row.matches_query("zhipu"));
4661 assert!(!row.matches_query("anthropic"));
4662 }
4663
4664 #[test]
4665 fn provider_dashboard_row_surfaces_zai_concurrency_cap() {
4666 let config = Config::default();
4667 let row =
4668 ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Deepseek, &config);
4669
4670 assert_eq!(
4671 row.request_concurrency.limit,
4672 Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
4673 );
4674 assert_eq!(row.request_concurrency.active, None);
4675 assert!(
4676 row.compact_hint().contains("req:cap 3"),
4677 "Z.ai's effective default cap must surface in /provider, got {:?}",
4678 row.compact_hint()
4679 );
4680 }
4681
4682 #[test]
4683 fn provider_dashboard_row_surfaces_active_provider_requests() {
4684 let config = Config::default();
4685 let runtime_status = ProviderRuntimeStatus {
4686 provider: ApiProvider::Zai,
4687 request_concurrency_limit: Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY),
4688 active_provider_requests: 2,
4689 };
4690 let mut picker = ProviderPickerView::new_with_runtime_status(
4691 ApiProvider::Zai,
4692 &config,
4693 Some(runtime_status),
4694 );
4695
4696 move_to_provider(&mut picker, ApiProvider::Zai);
4697 let row = &picker.rows[picker.selected_idx];
4698
4699 assert_eq!(
4700 row.request_concurrency.limit,
4701 Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
4702 );
4703 assert_eq!(row.request_concurrency.active, Some(2));
4704 assert!(
4705 row.compact_hint().contains("req:2/3"),
4706 "active runtime concurrency must surface in /provider, got {:?}",
4707 row.compact_hint()
4708 );
4709 }
4710
4711 #[test]
4712 fn provider_dashboard_row_surfaces_codex_reasoning_scale() {
4713 let config = Config {
4714 reasoning_effort: Some("max".to_string()),
4715 ..Config::default()
4716 };
4717 let row = ProviderDashboardRow::from_config(
4718 ApiProvider::OpenaiCodex,
4719 ApiProvider::OpenaiCodex,
4720 &config,
4721 );
4722
4723 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
4724 assert_eq!(
4725 row.reasoning.controls,
4726 vec![
4727 "low".to_string(),
4728 "medium".to_string(),
4729 "high".to_string(),
4730 "xhigh".to_string(),
4731 ]
4732 );
4733 assert_eq!(
4734 row.reasoning.stream_visibility,
4735 ProviderReasoningStreamVisibility::StructuredThinking
4736 );
4737 assert_eq!(row.reasoning.selected_control.as_deref(), Some("xhigh"));
4738 assert!(
4739 row.compact_hint()
4740 .contains("reasoning:low/medium/high/xhigh")
4741 );
4742 }
4743
4744 #[test]
4745 fn provider_dashboard_row_surfaces_capability_and_metadata_badges() {
4746 let config = Config {
4747 providers: Some(crate::config::ProvidersConfig {
4748 deepseek: crate::config::ProviderConfig {
4749 api_key: Some("deepseek-key".to_string()),
4750 ..Default::default()
4751 },
4752 ..Default::default()
4753 }),
4754 ..Config::default()
4755 };
4756 let row = ProviderDashboardRow::from_config(
4757 ApiProvider::Deepseek,
4758 ApiProvider::Deepseek,
4759 &config,
4760 );
4761
4762 // Metadata badges are projected from the resolved capability profile,
4763 // never hardcoded per UI surface.
4764 assert!(row.capabilities.context_window.is_some());
4765 assert!(row.capabilities.max_output.is_some());
4766 let hint = row.compact_hint();
4767 assert!(hint.contains("ctx:"), "metadata badge missing: {hint}");
4768 assert!(hint.contains("out:"), "metadata badge missing: {hint}");
4769 // Capability cluster present (tri-state; unknown renders `?`, never
4770 // silently omitted).
4771 for badge in ["tools:", "json:", "stream:", "cache:"] {
4772 assert!(
4773 hint.contains(badge),
4774 "capability badge {badge} missing: {hint}"
4775 );
4776 }
4777 }
4778
4779 #[test]
4780 fn provider_dashboard_row_classifies_model_origin() {
4781 // Default: no configured model override.
4782 let config = Config::default();
4783 let row = ProviderDashboardRow::from_config(
4784 ApiProvider::Deepseek,
4785 ApiProvider::Deepseek,
4786 &config,
4787 );
4788 assert_eq!(row.model_origin, ProviderModelOrigin::Default);
4789 assert!(row.compact_hint().contains("origin:default"));
4790
4791 // Saved: a configured model override for the provider.
4792 let config = Config {
4793 providers: Some(crate::config::ProvidersConfig {
4794 deepseek: crate::config::ProviderConfig {
4795 api_key: Some("k".to_string()),
4796 model: Some("deepseek-v4-flash".to_string()),
4797 ..Default::default()
4798 },
4799 ..Default::default()
4800 }),
4801 ..Config::default()
4802 };
4803 let row = ProviderDashboardRow::from_config(
4804 ApiProvider::Deepseek,
4805 ApiProvider::Deepseek,
4806 &config,
4807 );
4808 assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
4809 assert!(row.compact_hint().contains("origin:saved"));
4810 }
4811
4812 #[test]
4813 fn model_origin_classifier_covers_default_saved_custom() {
4814 assert_eq!(
4815 ProviderModelOrigin::for_provider(ApiProvider::Deepseek, false),
4816 ProviderModelOrigin::Default
4817 );
4818 assert_eq!(
4819 ProviderModelOrigin::for_provider(ApiProvider::Deepseek, true),
4820 ProviderModelOrigin::Saved
4821 );
4822 assert_eq!(
4823 ProviderModelOrigin::for_provider(ApiProvider::Custom, false),
4824 ProviderModelOrigin::Custom
4825 );
4826 // An explicit saved model still wins for a custom provider.
4827 assert_eq!(
4828 ProviderModelOrigin::for_provider(ApiProvider::Custom, true),
4829 ProviderModelOrigin::Saved
4830 );
4831 }
4832
4833 #[test]
4834 fn self_hosted_provider_row_marks_self_hosted_in_hint() {
4835 let _env_lock = crate::test_support::lock_test_env();
4836 let _sglang_key = crate::test_support::EnvVarGuard::remove("SGLANG_API_KEY");
4837 let _sglang_base_url = crate::test_support::EnvVarGuard::remove("SGLANG_BASE_URL");
4838 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
4839 let _vllm_base_url = crate::test_support::EnvVarGuard::remove("VLLM_BASE_URL");
4840 let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
4841 let _ollama_base_url = crate::test_support::EnvVarGuard::remove("OLLAMA_BASE_URL");
4842
4843 let config = Config::default();
4844 let row =
4845 ProviderDashboardRow::from_config(ApiProvider::Ollama, ApiProvider::Ollama, &config);
4846 assert_eq!(row.auth_status, ProviderAuthStatus::Local);
4847 assert!(
4848 row.compact_hint().contains("(self-hosted)"),
4849 "self-hosted hint missing: {}",
4850 row.compact_hint()
4851 );
4852
4853 let sglang =
4854 ProviderDashboardRow::from_config(ApiProvider::Sglang, ApiProvider::Sglang, &config);
4855 assert_eq!(sglang.auth_status, ProviderAuthStatus::Optional);
4856 assert!(
4857 sglang.compact_hint().contains("(self-hosted)"),
4858 "self-hosted hint missing for SGLang: {}",
4859 sglang.compact_hint()
4860 );
4861 }
4862
4863 #[test]
4864 fn protected_self_hosted_row_requires_its_configured_auth_mode() {
4865 let _env_lock = crate::test_support::lock_test_env();
4866 let temp = tempfile::tempdir().expect("isolated credential home");
4867 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
4868 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4869 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
4870 let _vllm_base_url = crate::test_support::EnvVarGuard::remove("VLLM_BASE_URL");
4871 let config = Config {
4872 provider: Some("vllm".to_string()),
4873 providers: Some(crate::config::ProvidersConfig {
4874 vllm: crate::config::ProviderConfig {
4875 auth_mode: Some("api_key".to_string()),
4876 ..Default::default()
4877 },
4878 ..Default::default()
4879 }),
4880 ..Config::default()
4881 };
4882
4883 let row = ProviderDashboardRow::from_config(ApiProvider::Vllm, ApiProvider::Vllm, &config);
4884
4885 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
4886 assert_eq!(row.credential_state, CredentialState::MissingKey);
4887 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
4888 assert!(row.compact_hint().contains("(self-hosted)"));
4889 }
4890
4891 #[test]
4892 fn self_hosted_reasoning_visibility_covers_vllm() {
4893 assert_eq!(
4894 default_reasoning_stream_visibility(ApiProvider::Sglang),
4895 ProviderReasoningStreamVisibility::StructuredThinking
4896 );
4897 assert_eq!(
4898 default_reasoning_stream_visibility(ApiProvider::Vllm),
4899 ProviderReasoningStreamVisibility::StructuredThinking
4900 );
4901 }
4902
4903 #[test]
4904 fn humanize_token_count_is_compact_and_marks_unknown() {
4905 assert_eq!(humanize_token_count(None), "?");
4906 assert_eq!(humanize_token_count(Some(1_000_000)), "1M");
4907 assert_eq!(humanize_token_count(Some(1_500_000)), "1.5M");
4908 assert_eq!(humanize_token_count(Some(131_072)), "131K");
4909 assert_eq!(humanize_token_count(Some(512)), "512");
4910 }
4911
4912 #[test]
4913 fn provider_dashboard_row_uses_route_resolver_for_custom_openai_endpoint() {
4914 let config = Config {
4915 providers: Some(crate::config::ProvidersConfig {
4916 openai: crate::config::ProviderConfig {
4917 api_key: Some("openai-key".to_string()),
4918 base_url: Some("http://localhost:9000/v1".to_string()),
4919 model: Some("custom-model".to_string()),
4920 ..Default::default()
4921 },
4922 ..Default::default()
4923 }),
4924 ..Config::default()
4925 };
4926 let row =
4927 ProviderDashboardRow::from_config(ApiProvider::Openai, ApiProvider::Openai, &config);
4928
4929 assert_eq!(row.provider_id, "openai");
4930 assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
4931 assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
4932 assert_eq!(row.base_url, "http://localhost:9000/v1");
4933 assert_eq!(row.default_route.logical_model, "custom-model");
4934 assert_eq!(row.default_route.wire_model, "custom-model");
4935 assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
4936 }
4937
4938 #[test]
4939 fn custom_endpoint_cannot_claim_official_xai_oauth_readiness() {
4940 let _lock = crate::test_support::lock_test_env();
4941 let temp = tempfile::tempdir().expect("isolated oauth home");
4942 let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
4943 let missing_grok_auth = temp.path().join("missing.json");
4944 let _grok_auth = EnvVarGuard::set(
4945 "GROK_AUTH_PATH",
4946 missing_grok_auth.to_str().expect("utf8 test path"),
4947 );
4948 let config = Config {
4949 provider: Some("xai".to_string()),
4950 providers: Some(crate::config::ProvidersConfig {
4951 xai: crate::config::ProviderConfig {
4952 base_url: Some("https://gateway.example.test/v1".to_string()),
4953 model: Some("private-grok".to_string()),
4954 auth_mode: Some("oauth".to_string()),
4955 ..Default::default()
4956 },
4957 ..Default::default()
4958 }),
4959 ..Config::default()
4960 };
4961
4962 let row = ProviderDashboardRow::from_config(ApiProvider::Xai, ApiProvider::Xai, &config);
4963
4964 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
4965 assert_eq!(row.credential_state, CredentialState::MissingKey);
4966 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
4967 assert!(!row.compact_hint().contains("oauth"));
4968 }
4969
4970 #[test]
4971 fn explicit_no_auth_custom_row_is_distinct_and_usable() {
4972 let custom = std::collections::HashMap::from([(
4973 "no-auth-gateway".to_string(),
4974 crate::config::ProviderConfig {
4975 kind: Some("openai-compatible".to_string()),
4976 base_url: Some("https://gateway.example.test/v1".to_string()),
4977 model: Some("private-model".to_string()),
4978 auth_mode: Some("no-auth".to_string()),
4979 ..Default::default()
4980 },
4981 )]);
4982 let config = Config {
4983 provider: Some("no-auth-gateway".to_string()),
4984 providers: Some(crate::config::ProvidersConfig {
4985 custom,
4986 ..Default::default()
4987 }),
4988 ..Config::default()
4989 };
4990
4991 let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
4992 let row = picker
4993 .rows
4994 .iter()
4995 .find(|row| row.provider_id == "no-auth-gateway")
4996 .expect("configured no-auth row");
4997
4998 assert_eq!(row.auth_status, ProviderAuthStatus::NoAuth);
4999 assert_eq!(row.credential_state, CredentialState::NoAuth);
5000 assert_eq!(row.readiness, ResolvedProviderReadiness::NoAuthUnchecked);
5001 assert!(picker.selected_has_key());
5002 assert!(row.compact_hint().contains("auth:none"));
5003 }
5004
5005 #[test]
5006 fn unresolved_custom_auth_metadata_does_not_mark_picker_row_configured() {
5007 let custom = std::collections::HashMap::from([(
5008 "metadata-only".to_string(),
5009 crate::config::ProviderConfig {
5010 kind: Some("openai-compatible".to_string()),
5011 base_url: Some("https://gateway.example.test/v1".to_string()),
5012 model: Some("private-model".to_string()),
5013 auth: Some(codewhale_config::ProviderAuthSourceToml {
5014 source: codewhale_config::AuthSourceKind::Command,
5015 command: vec!["secret-tool".to_string(), "lookup".to_string()],
5016 timeout_ms: Some(2_000),
5017 secret_id: None,
5018 }),
5019 ..Default::default()
5020 },
5021 )]);
5022 let config = Config {
5023 provider: Some("metadata-only".to_string()),
5024 providers: Some(crate::config::ProvidersConfig {
5025 custom,
5026 ..Default::default()
5027 }),
5028 ..Config::default()
5029 };
5030
5031 let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
5032 let row = picker
5033 .rows
5034 .iter()
5035 .find(|row| row.provider_id == "metadata-only")
5036 .expect("metadata-only row remains visible for repair");
5037
5038 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
5039 assert_eq!(row.credential_state, CredentialState::MissingKey);
5040 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
5041 }
5042
5043 #[test]
5044 fn provider_picker_lists_configured_custom_provider_readiness() {
5045 let _lock = crate::test_support::lock_test_env();
5046 let _example_key = EnvVarGuard::remove("EXAMPLE_API_KEY");
5047 let mut custom = std::collections::HashMap::new();
5048 custom.insert(
5049 "my_thing".to_string(),
5050 crate::config::ProviderConfig {
5051 kind: Some("openai-compatible".to_string()),
5052 base_url: Some("https://api.example.com/v1".to_string()),
5053 model: Some("vendor/custom-model-v1".to_string()),
5054 api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
5055 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
5056 ..Default::default()
5057 },
5058 );
5059 let config = Config {
5060 provider: Some("my_thing".to_string()),
5061 providers: Some(crate::config::ProvidersConfig {
5062 custom,
5063 ..Default::default()
5064 }),
5065 ..Config::default()
5066 };
5067
5068 let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
5069 let row = picker
5070 .rows
5071 .iter()
5072 .find(|row| row.provider_id == "my_thing")
5073 .expect("configured custom provider row");
5074
5075 assert_eq!(row.provider, ApiProvider::Custom);
5076 assert_eq!(row.display_name, "my_thing (custom)");
5077 assert_eq!(row.kind, "openai-compatible");
5078 assert!(row.is_active);
5079 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
5080 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
5081 assert_eq!(row.base_url, "https://api.example.com/v1");
5082 assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
5083 assert_eq!(row.default_route.logical_model, "vendor/custom-model-v1");
5084 assert_eq!(row.default_route.wire_model, "vendor/custom-model-v1");
5085 assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
5086 assert!(
5087 row.messages
5088 .iter()
5089 .any(|message| message.contains("EXAMPLE_API_KEY")),
5090 "custom row should name the configured auth env var: {:?}",
5091 row.messages
5092 );
5093 assert_eq!(picker.rows[picker.selected_idx].provider_id, "my_thing");
5094 }
5095
5096 #[test]
5097 fn provider_picker_marks_only_exact_active_custom_row() {
5098 let custom = std::collections::HashMap::from([
5099 (
5100 "custom-a".to_string(),
5101 crate::config::ProviderConfig {
5102 kind: Some("openai-compatible".to_string()),
5103 base_url: Some("http://127.0.0.1:18181/v1".to_string()),
5104 model: Some("model-a".to_string()),
5105 api_key: Some("test-key-a".to_string()),
5106 ..Default::default()
5107 },
5108 ),
5109 (
5110 "custom-b".to_string(),
5111 crate::config::ProviderConfig {
5112 kind: Some("openai-compatible".to_string()),
5113 base_url: Some("http://127.0.0.1:18182/v1".to_string()),
5114 model: Some("model-b".to_string()),
5115 api_key: Some("test-key-b".to_string()),
5116 ..Default::default()
5117 },
5118 ),
5119 ]);
5120 let config = Config {
5121 provider: Some("custom-a".to_string()),
5122 providers: Some(crate::config::ProvidersConfig {
5123 custom,
5124 ..Default::default()
5125 }),
5126 ..Config::default()
5127 };
5128
5129 let rows = custom_provider_dashboard_rows(ApiProvider::Custom, &config, None);
5130 let active_ids: Vec<_> = rows
5131 .iter()
5132 .filter(|row| row.is_active)
5133 .map(|row| row.provider_id.as_str())
5134 .collect();
5135
5136 assert_eq!(active_ids, vec!["custom-a"]);
5137 }
5138
5139 #[test]
5140 fn provider_picker_marks_custom_provider_ready_when_env_auth_is_set() {
5141 let _lock = crate::test_support::lock_test_env();
5142 let _example_key = EnvVarGuard::set("EXAMPLE_API_KEY", "sk-test");
5143 let mut custom = std::collections::HashMap::new();
5144 custom.insert(
5145 "my_thing".to_string(),
5146 crate::config::ProviderConfig {
5147 kind: Some("openai-compatible".to_string()),
5148 base_url: Some("https://api.example.com/v1".to_string()),
5149 model: Some("custom-model-v1".to_string()),
5150 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
5151 ..Default::default()
5152 },
5153 );
5154 let config = Config {
5155 provider: Some("my_thing".to_string()),
5156 providers: Some(crate::config::ProvidersConfig {
5157 custom,
5158 ..Default::default()
5159 }),
5160 ..Config::default()
5161 };
5162
5163 let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
5164 let row = picker
5165 .rows
5166 .iter()
5167 .find(|row| row.provider_id == "my_thing")
5168 .expect("configured custom provider row");
5169
5170 assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
5171 assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
5172 assert!(row.has_key);
5173 assert!(
5174 !row.messages
5175 .iter()
5176 .any(|message| message.contains("EXAMPLE_API_KEY")),
5177 "configured custom auth should not report missing env var: {:?}",
5178 row.messages
5179 );
5180 }
5181
5182 #[test]
5183 fn custom_provider_form_emits_named_provider_without_secret_value() {
5184 let config = Config::default();
5185 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5186
5187 assert!(matches!(
5188 picker.handle_key(key(KeyCode::Char('c'))),
5189 ViewAction::None
5190 ));
5191 assert_eq!(picker.stage, Stage::CustomForm);
5192 for ch in "acme_ai".chars() {
5193 picker.handle_key(key(KeyCode::Char(ch)));
5194 }
5195 picker.handle_key(key(KeyCode::Enter));
5196 for ch in "https://api.acme.example/v1".chars() {
5197 picker.handle_key(key(KeyCode::Char(ch)));
5198 }
5199 picker.handle_key(key(KeyCode::Enter));
5200 for ch in "acme/code-1".chars() {
5201 picker.handle_key(key(KeyCode::Char(ch)));
5202 }
5203 picker.handle_key(key(KeyCode::Enter));
5204 for ch in "ACME_API_KEY".chars() {
5205 picker.handle_key(key(KeyCode::Char(ch)));
5206 }
5207
5208 let action = picker.handle_key(key(KeyCode::Enter));
5209 match action {
5210 ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
5211 provider_id,
5212 base_url,
5213 model,
5214 api_key_env,
5215 }) => {
5216 assert_eq!(provider_id, "acme_ai");
5217 assert_eq!(base_url, "https://api.acme.example/v1");
5218 assert_eq!(model.as_deref(), Some("acme/code-1"));
5219 assert_eq!(api_key_env.as_deref(), Some("ACME_API_KEY"));
5220 }
5221 other => panic!("expected custom provider submit event, got {other:?}"),
5222 }
5223 }
5224
5225 #[test]
5226 fn named_custom_provider_selection_preserves_provider_id() {
5227 let mut custom = std::collections::HashMap::new();
5228 custom.insert(
5229 "local_acme".to_string(),
5230 crate::config::ProviderConfig {
5231 kind: Some("openai-compatible".to_string()),
5232 base_url: Some("http://localhost:9000/v1".to_string()),
5233 model: Some("acme/code-1".to_string()),
5234 ..Default::default()
5235 },
5236 );
5237 let config = Config {
5238 provider: Some("local_acme".to_string()),
5239 providers: Some(crate::config::ProvidersConfig {
5240 custom,
5241 ..Default::default()
5242 }),
5243 ..Config::default()
5244 };
5245 let mut picker = ProviderPickerView::new(ApiProvider::Custom, &config);
5246
5247 let action = picker.handle_key(key(KeyCode::Enter));
5248
5249 match action {
5250 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
5251 provider,
5252 provider_id,
5253 }) => {
5254 assert_eq!(provider, ApiProvider::Custom);
5255 assert_eq!(provider_id.as_deref(), Some("local_acme"));
5256 }
5257 other => panic!("expected named custom provider apply, got {other:?}"),
5258 }
5259 }
5260
5261 #[test]
5262 fn named_custom_provider_model_shortcut_preserves_provider_id() {
5263 let mut custom = std::collections::HashMap::new();
5264 custom.insert(
5265 "local_acme".to_string(),
5266 crate::config::ProviderConfig {
5267 kind: Some("openai-compatible".to_string()),
5268 base_url: Some("http://localhost:9000/v1".to_string()),
5269 model: Some("acme/code-1".to_string()),
5270 ..Default::default()
5271 },
5272 );
5273 let config = Config {
5274 provider: Some("local_acme".to_string()),
5275 providers: Some(crate::config::ProvidersConfig {
5276 custom,
5277 ..Default::default()
5278 }),
5279 ..Config::default()
5280 };
5281 let mut picker = ProviderPickerView::new(ApiProvider::Custom, &config);
5282
5283 let action = picker.handle_key(key(KeyCode::Char('m')));
5284
5285 match action {
5286 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
5287 provider,
5288 provider_id,
5289 }) => {
5290 assert_eq!(provider, ApiProvider::Custom);
5291 assert_eq!(provider_id.as_deref(), Some("local_acme"));
5292 }
5293 other => panic!("expected named custom provider model shortcut, got {other:?}"),
5294 }
5295 }
5296
5297 #[test]
5298 fn provider_dashboard_row_surfaces_anthropic_wire_protocol() {
5299 let config = Config::default();
5300 let row = ProviderDashboardRow::from_config(
5301 ApiProvider::Anthropic,
5302 ApiProvider::Deepseek,
5303 &config,
5304 );
5305
5306 assert_eq!(row.provider_id, "anthropic");
5307 assert_eq!(row.supported_protocols, vec!["anthropic".to_string()]);
5308 assert_eq!(row.catalog_status, ProviderCatalogStatus::Bundled);
5309 assert!(row.available_model_count >= 3);
5310 }
5311
5312 #[test]
5313 fn provider_dashboard_row_surfaces_openmodel_messages_route() {
5314 let _lock = crate::test_support::lock_test_env();
5315 let _openmodel_key = EnvVarGuard::remove("OPENMODEL_API_KEY");
5316 let config = Config::default();
5317 let row = ProviderDashboardRow::from_config(
5318 ApiProvider::Openmodel,
5319 ApiProvider::Deepseek,
5320 &config,
5321 );
5322
5323 assert_eq!(row.provider_id, "openmodel");
5324 assert_eq!(row.display_name, "OpenModel");
5325 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
5326 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
5327 assert_eq!(row.supported_protocols, vec!["anthropic".to_string()]);
5328 assert_eq!(row.base_url, crate::config::DEFAULT_OPENMODEL_BASE_URL);
5329 assert_eq!(row.default_route.logical_model, "deepseek-v4-flash");
5330 assert_eq!(row.default_route.wire_model, "deepseek-v4-flash");
5331 assert!(
5332 row.messages
5333 .iter()
5334 .any(|message| message.contains("missing OPENMODEL_API_KEY"))
5335 );
5336 }
5337
5338 #[test]
5339 fn provider_dashboard_row_marks_missing_api_key_as_needs_key() {
5340 let _lock = crate::test_support::lock_test_env();
5341 let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
5342 let config = Config::default();
5343 let row = ProviderDashboardRow::from_config(
5344 ApiProvider::Openrouter,
5345 ApiProvider::Deepseek,
5346 &config,
5347 );
5348
5349 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
5350 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
5351 assert_eq!(row.readiness.label(), "missing key");
5352 let hint = row.compact_hint();
5353 assert!(hint.contains("key:not-set"));
5354 assert!(!hint.contains("needs-auth"));
5355 assert!(!hint.contains("auth:missing"));
5356 assert!(
5357 row.messages
5358 .iter()
5359 .any(|message| message.contains("missing OPENROUTER_API_KEY"))
5360 );
5361 }
5362
5363 #[test]
5364 fn modelstudio_family_key_marks_all_variants_configured() {
5365 let _guard = crate::test_support::lock_test_env();
5366 let tmp = tempfile::TempDir::new().expect("tempdir");
5367 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
5368 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
5369 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
5370 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
5371 let _ms_key = crate::test_support::EnvVarGuard::remove("MODELSTUDIO_API_KEY");
5372 let _dashscope_key = crate::test_support::EnvVarGuard::remove("DASHSCOPE_API_KEY");
5373 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
5374 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
5375
5376 // One saved key on the Token Plan variant, marked by the save path.
5377 codewhale_secrets::Secrets::auto_detect()
5378 .set("modelstudio-token-plan", "ms-family-key")
5379 .expect("seed family slot");
5380 let config = Config {
5381 provider: Some("deepseek".to_string()),
5382 providers: Some(crate::config::ProvidersConfig {
5383 modelstudio_token_plan: crate::config::ProviderConfig {
5384 auth_mode: Some("api_key".to_string()),
5385 ..Default::default()
5386 },
5387 ..Default::default()
5388 }),
5389 ..Config::default()
5390 };
5391
5392 for variant in [
5393 ApiProvider::ModelstudioTokenPlan,
5394 ApiProvider::ModelstudioTokenPlanAnthropic,
5395 ApiProvider::ModelstudioCodingPlan,
5396 ApiProvider::ModelstudioCodingPlanAnthropic,
5397 ] {
5398 let row = ProviderDashboardRow::from_config(variant, ApiProvider::Deepseek, &config);
5399 assert_eq!(
5400 row.auth_status,
5401 ProviderAuthStatus::Configured,
5402 "{variant:?} must resolve the family's one saved key"
5403 );
5404 }
5405 }
5406
5407 #[test]
5408 fn provider_dashboard_row_marks_route_resolver_errors_as_invalid() {
5409 let config = Config {
5410 api_key: Some("deepseek-key".to_string()),
5411 providers: Some(crate::config::ProvidersConfig {
5412 deepseek: crate::config::ProviderConfig {
5413 model: Some("anthropic/claude-foreign".to_string()),
5414 ..Default::default()
5415 },
5416 ..Default::default()
5417 }),
5418 ..Config::default()
5419 };
5420 let row = ProviderDashboardRow::from_config(
5421 ApiProvider::Deepseek,
5422 ApiProvider::Deepseek,
5423 &config,
5424 );
5425
5426 assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
5427 assert_eq!(row.readiness, ResolvedProviderReadiness::InvalidRoute);
5428 assert_eq!(row.default_route.wire_model, "unresolved");
5429 assert!(
5430 row.messages
5431 .iter()
5432 .any(|message| message.contains("route validation failed"))
5433 );
5434 }
5435
5436 #[test]
5437 fn provider_dashboard_render_includes_route_protocol_usage_and_base_url() {
5438 let config = Config {
5439 providers: Some(crate::config::ProvidersConfig {
5440 openai: crate::config::ProviderConfig {
5441 api_key: Some("openai-key".to_string()),
5442 base_url: Some("http://localhost:9000/v1".to_string()),
5443 model: Some("custom-model".to_string()),
5444 ..Default::default()
5445 },
5446 ..Default::default()
5447 }),
5448 ..Config::default()
5449 };
5450 let picker = ProviderPickerView::new(ApiProvider::Openai, &config);
5451
5452 let rendered = render_text(&picker, 124, 18);
5453
5454 assert!(rendered.contains("key:configured"));
5455 assert!(!rendered.contains("auth:configured"));
5456 assert!(rendered.contains("Route: custom-model"));
5457 assert!(rendered.contains("chat"));
5458 assert!(rendered.contains("cost: unknown"));
5459 assert!(rendered.contains("Endpoint: http://localhost:9000/v1"));
5460 }
5461
5462 #[test]
5463 fn ollama_is_selectable_without_key() {
5464 let config = Config::default();
5465 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5466 move_to_provider(&mut picker, ApiProvider::Ollama);
5467 assert_eq!(picker.selected_provider(), ApiProvider::Ollama);
5468 assert!(picker.selected_has_key());
5469 let action = picker.handle_key(key(KeyCode::Enter));
5470 match action {
5471 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
5472 provider,
5473 provider_id,
5474 }) => {
5475 assert_eq!(provider, ApiProvider::Ollama);
5476 assert_eq!(provider_id, None);
5477 }
5478 other => panic!("expected ProviderPickerApplied, got {other:?}"),
5479 }
5480 }
5481
5482 #[test]
5483 fn pressing_m_opens_models_for_selected_provider() {
5484 let config = Config::default();
5485 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5486 move_to_provider(&mut picker, ApiProvider::Openrouter);
5487
5488 let action = picker.handle_key(key(KeyCode::Char('m')));
5489
5490 // #3083: `m` jumps to the model picker scoped to the highlighted
5491 // provider rather than acting as a type-ahead seek.
5492 match action {
5493 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
5494 provider,
5495 provider_id,
5496 }) => {
5497 assert_eq!(provider, ApiProvider::Openrouter);
5498 assert_eq!(provider_id, None);
5499 }
5500 other => panic!("expected ProviderPickerOpenModels, got {other:?}"),
5501 }
5502 }
5503
5504 #[test]
5505 fn pressing_uppercase_m_also_opens_models() {
5506 let config = Config::default();
5507 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5508
5509 // Case-insensitive like the `R` edit-key affordance: a bare `M` works.
5510 let action = picker.handle_key(key(KeyCode::Char('M')));
5511
5512 match action {
5513 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
5514 provider,
5515 provider_id,
5516 }) => {
5517 assert_eq!(provider, ApiProvider::Deepseek);
5518 assert_eq!(provider_id, None);
5519 }
5520 other => panic!("expected ProviderPickerOpenModels, got {other:?}"),
5521 }
5522 }
5523
5524 #[test]
5525 fn picker_marks_active_provider_as_initial_selection() {
5526 let config = Config::default();
5527 let picker = ProviderPickerView::new(ApiProvider::Openrouter, &config);
5528 assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
5529 assert!(picker.rows[picker.selected_idx].is_active);
5530 }
5531
5532 #[test]
5533 fn list_navigation_wraps_between_first_and_last_provider() {
5534 let config = Config::default();
5535 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5536 // Wrap across the full catalog (#3830), not just the configured
5537 // subset, which would only contain the active provider here.
5538 picker.toggle_view();
5539 let first = picker.rows.first().expect("non-empty list").provider;
5540 let last = picker.rows.last().expect("non-empty list").provider;
5541
5542 // Order-independent: jump to the first entry, wrap up to the last, back down.
5543 picker.selected_idx = 0;
5544 picker.handle_key(key(KeyCode::Up));
5545 assert_eq!(picker.selected_provider(), last);
5546
5547 picker.handle_key(key(KeyCode::Down));
5548 assert_eq!(picker.selected_provider(), first);
5549 }
5550
5551 #[test]
5552 fn enter_with_no_key_transitions_to_key_entry_stage() {
5553 let config = Config::default();
5554 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5555 // Move to OpenRouter, which has no key in default config.
5556 move_to_provider(&mut picker, ApiProvider::Openrouter);
5557 assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
5558 let action = picker.handle_key(key(KeyCode::Enter));
5559 assert!(matches!(action, ViewAction::None));
5560 assert_eq!(picker.stage, Stage::KeyEntry);
5561 }
5562
5563 #[test]
5564 fn enter_with_existing_key_emits_apply_and_closes() {
5565 let config = Config {
5566 api_key: Some("existing-deepseek-key".to_string()),
5567 ..Config::default()
5568 };
5569 let mut picker = ProviderPickerView::new(ApiProvider::NvidiaNim, &config);
5570 // Navigate to DeepSeek, which has a key from the top-level config.
5571 move_to_provider(&mut picker, ApiProvider::Deepseek);
5572 let action = picker.handle_key(key(KeyCode::Enter));
5573 match action {
5574 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
5575 provider,
5576 provider_id,
5577 }) => {
5578 assert_eq!(provider, ApiProvider::Deepseek);
5579 assert_eq!(provider_id, None);
5580 }
5581 other => panic!("expected ProviderPickerApplied, got {other:?}"),
5582 }
5583 }
5584
5585 #[test]
5586 fn new_for_missing_auth_opens_key_entry_focused_on_target() {
5587 // #3830: the missing-auth handoff drops the user onto the target
5588 // provider's key prompt, not a dead-end error.
5589 let config = Config::default();
5590 let picker = ProviderPickerView::new_for_missing_auth(
5591 ApiProvider::Deepseek,
5592 ApiProvider::Anthropic,
5593 &config,
5594 None,
5595 )
5596 .expect("Anthropic has a picker row");
5597 assert_eq!(picker.stage, Stage::KeyEntry);
5598 assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
5599 }
5600
5601 #[test]
5602 fn setup_catalog_shows_all_providers_from_configured_view() {
5603 let config = Config::default();
5604 let picker = ProviderPickerView::new_for_setup(ApiProvider::Deepseek, None, &config, None);
5605
5606 assert_eq!(picker.stage, Stage::List);
5607 assert_eq!(picker.view, ProviderListView::Catalog);
5608 assert_eq!(picker.visible_row_count(), picker.rows.len());
5609 let mut listed = picker
5610 .rows
5611 .iter()
5612 .map(|row| row.provider)
5613 .collect::<Vec<_>>();
5614 // With no configured custom providers, the catalog keeps the Custom
5615 // entry so a custom endpoint can still be created from setup. The
5616 // canonical universe is the user-facing catalog (one identity per
5617 // vendor): dual-wire dialects are `wire` config and plan variants are
5618 // `mode`/base_url, not picker rows.
5619 let mut expected = ApiProvider::catalog().to_vec();
5620 listed.sort_by_key(|provider| provider.as_str());
5621 expected.sort_by_key(|provider| provider.as_str());
5622 assert_eq!(
5623 listed, expected,
5624 "setup must use the canonical provider universe"
5625 );
5626 }
5627
5628 #[test]
5629 fn setup_catalog_focuses_missing_provider_key_entry() {
5630 let _lock = crate::test_support::lock_test_env();
5631 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
5632 let config = Config::default();
5633 let picker = ProviderPickerView::new_for_setup(
5634 ApiProvider::Deepseek,
5635 Some(ApiProvider::Anthropic),
5636 &config,
5637 None,
5638 );
5639
5640 assert_eq!(picker.view, ProviderListView::Catalog);
5641 assert_eq!(picker.stage, Stage::KeyEntry);
5642 assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
5643 assert!(picker.api_key_input.is_empty());
5644 }
5645
5646 /// #4763: onboarding focuses the persisted route but must still open on
5647 /// the navigable list. Jumping straight into key/OAuth entry hid the
5648 /// provider catalog from returning users with a missing key.
5649 #[test]
5650 fn onboarding_catalog_focuses_missing_provider_without_leaving_the_list() {
5651 let _lock = crate::test_support::lock_test_env();
5652 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
5653 let config = Config::default();
5654 let picker = ProviderPickerView::new_for_onboarding(
5655 ApiProvider::Deepseek,
5656 Some(ApiProvider::Anthropic),
5657 &config,
5658 None,
5659 );
5660
5661 assert_eq!(picker.stage, Stage::List);
5662 assert_eq!(picker.view, ProviderListView::Catalog);
5663 assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
5664 assert_eq!(
5665 picker.visible_row_count(),
5666 picker.rows.len(),
5667 "onboarding must show the whole provider catalog"
5668 );
5669 }
5670
5671 #[test]
5672 fn onboarding_catalog_honors_typed_credentials_for_every_builtin_provider() {
5673 use codewhale_config::provider::CredentialAcquisition;
5674
5675 let _global_env = crate::test_support::lock_test_env();
5676 let home = tempfile::tempdir().expect("isolated provider catalog home");
5677 let _home = EnvVarGuard::set("HOME", home.path().to_string_lossy().as_ref());
5678 let _codewhale_home =
5679 EnvVarGuard::set("CODEWHALE_HOME", home.path().to_string_lossy().as_ref());
5680 let _codex_home = EnvVarGuard::set("CODEX_HOME", home.path().to_string_lossy().as_ref());
5681 let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
5682 let mut key_envs = ApiProvider::all()
5683 .iter()
5684 .flat_map(|provider| provider.env_vars().iter().copied())
5685 .collect::<Vec<_>>();
5686 key_envs.sort_unstable();
5687 key_envs.dedup();
5688 let _missing_keys = key_envs
5689 .into_iter()
5690 .map(EnvVarGuard::remove)
5691 .collect::<Vec<_>>();
5692 let config = Config::default();
5693
5694 // Every provider the catalog actually lists. Hidden dual-wire/plan
5695 // variants share their vendor primary's row and credential metadata,
5696 // so `ApiProvider::all()` cannot be driven through the visible list.
5697 for provider in ApiProvider::catalog().iter().copied() {
5698 let mut picker = ProviderPickerView::new_for_onboarding(
5699 ApiProvider::Deepseek,
5700 Some(provider),
5701 &config,
5702 None,
5703 );
5704 assert_eq!(picker.selected_provider(), provider, "{provider:?}");
5705 assert_eq!(picker.stage, Stage::List, "{provider:?}");
5706
5707 let action = picker.handle_key(key(KeyCode::Enter));
5708 match provider.credential_help().acquisition {
5709 CredentialAcquisition::ApiKey => {
5710 assert!(matches!(action, ViewAction::None), "{provider:?}");
5711 assert!(
5712 matches!(picker.stage, Stage::KeyEntry | Stage::StepfunBillingRoute),
5713 "{provider:?} entered {:?}",
5714 picker.stage
5715 );
5716 }
5717 CredentialAcquisition::ApiKeyOrOAuth => {
5718 assert_eq!(provider, ApiProvider::Xai, "{provider:?}");
5719 assert!(matches!(action, ViewAction::None), "{provider:?}");
5720 let choices = render_text(&picker, 80, 24);
5721 assert!(choices.contains("API key"), "{choices}");
5722 assert!(choices.contains("device OAuth"), "{choices}");
5723
5724 // Choice 1 is an ordinary API-key path. Text remains a key;
5725 // it is never reinterpreted as an OAuth bearer token.
5726 assert!(matches!(
5727 picker.handle_key(key(KeyCode::Char('1'))),
5728 ViewAction::None
5729 ));
5730 assert!(matches!(
5731 picker.handle_key(key(KeyCode::Enter)),
5732 ViewAction::None
5733 ));
5734 assert_eq!(picker.stage, Stage::KeyEntry);
5735 for ch in "violet-".chars() {
5736 picker.handle_key(key(KeyCode::Char(ch)));
5737 }
5738 assert!(picker.handle_paste("otter-key"));
5739 let key_text = "violet-otter-key";
5740 assert_eq!(picker.api_key_input, key_text);
5741 for (width, height) in [(80, 24), (120, 32)] {
5742 let rendered = render_text(&picker, width, height);
5743 assert!(!rendered.contains(key_text), "{width}x{height}: {rendered}");
5744 assert!(rendered.contains('*'), "{width}x{height}: {rendered}");
5745 }
5746 assert!(matches!(
5747 picker.handle_key(key(KeyCode::Enter)),
5748 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
5749 provider: ApiProvider::Xai,
5750 provider_id: None,
5751 api_key,
5752 base_url: None,
5753 }) if api_key == key_text
5754 ));
5755
5756 // Choice 2 is the provider-native device flow and emits only
5757 // the request event; the picker never manufactures a token.
5758 let mut oauth = ProviderPickerView::new_for_onboarding(
5759 ApiProvider::Deepseek,
5760 Some(ApiProvider::Xai),
5761 &config,
5762 None,
5763 );
5764 assert!(matches!(
5765 oauth.handle_key(key(KeyCode::Enter)),
5766 ViewAction::None
5767 ));
5768 assert!(matches!(
5769 oauth.handle_key(key(KeyCode::Char('2'))),
5770 ViewAction::None
5771 ));
5772 assert!(matches!(
5773 oauth.handle_key(key(KeyCode::Enter)),
5774 ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
5775 ));
5776 }
5777 CredentialAcquisition::LocalOptional => assert!(matches!(
5778 action,
5779 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
5780 provider: applied,
5781 provider_id: None,
5782 }) if applied == provider
5783 )),
5784 CredentialAcquisition::OAuth => {
5785 assert!(matches!(action, ViewAction::None), "{provider:?}");
5786 assert_eq!(picker.stage, Stage::KeyEntry, "{provider:?}");
5787 assert!(picker.handle_paste("fixture-oauth-paste"));
5788 assert!(
5789 picker.api_key_input.is_empty(),
5790 "{provider:?} must reject key paste"
5791 );
5792 }
5793 CredentialAcquisition::Configuration => {
5794 assert_eq!(provider, ApiProvider::Custom);
5795 assert!(matches!(action, ViewAction::None));
5796 assert_eq!(picker.stage, Stage::CustomForm);
5797 assert!(picker.api_key_input.is_empty());
5798 }
5799 }
5800 }
5801 }
5802
5803 #[test]
5804 fn credential_draft_is_masked_and_escape_drops_it_without_persistence() {
5805 let _global_env = crate::test_support::lock_test_env();
5806 let home = tempfile::tempdir().expect("isolated credential draft home");
5807 let _home = EnvVarGuard::set("HOME", home.path().to_string_lossy().as_ref());
5808 let _codewhale_home =
5809 EnvVarGuard::set("CODEWHALE_HOME", home.path().to_string_lossy().as_ref());
5810 let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
5811 let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
5812 let config = Config::default();
5813 let draft = ["violet", "otter", "draft", "7361"].join("-");
5814 let mut picker = ProviderPickerView::new_for_missing_auth(
5815 ApiProvider::Deepseek,
5816 ApiProvider::Openrouter,
5817 &config,
5818 None,
5819 )
5820 .expect("OpenRouter key editor");
5821
5822 let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
5823 assert!(matches!(picker.handle_key(ctrl_v), ViewAction::None));
5824 assert!(
5825 picker.api_key_input.is_empty(),
5826 "shortcut must not type `v`"
5827 );
5828 let shifted_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT);
5829 assert!(matches!(picker.handle_key(shifted_v), ViewAction::None));
5830 assert_eq!(
5831 picker.api_key_input, "V",
5832 "shifted credential text is valid"
5833 );
5834 assert!(matches!(
5835 picker.handle_key(key(KeyCode::Backspace)),
5836 ViewAction::None
5837 ));
5838 assert!(picker.handle_paste(&draft));
5839 assert_eq!(picker.api_key_input, draft);
5840 for (width, height) in [(80, 24), (120, 32)] {
5841 let rendered = render_text(&picker, width, height);
5842 assert!(!rendered.contains(&draft), "{width}x{height}: {rendered}");
5843 assert!(rendered.contains('*'), "{width}x{height}: {rendered}");
5844 }
5845
5846 assert!(matches!(
5847 picker.handle_key(key(KeyCode::Esc)),
5848 ViewAction::None
5849 ));
5850 assert_eq!(picker.stage, Stage::List);
5851 assert!(picker.api_key_input.is_empty());
5852 assert_eq!(
5853 std::fs::read_dir(home.path())
5854 .expect("isolated home remains readable")
5855 .count(),
5856 0,
5857 "Esc must not create config or credential-backend files"
5858 );
5859 }
5860
5861 /// #4763: Escape backs out one stage at a time — key entry returns to the
5862 /// list, and only the list dismisses the picker.
5863 #[test]
5864 fn onboarding_escape_walks_key_entry_back_to_the_list_then_dismisses() {
5865 let _lock = crate::test_support::lock_test_env();
5866 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
5867 let config = Config::default();
5868 let mut picker = ProviderPickerView::new_for_onboarding(
5869 ApiProvider::Deepseek,
5870 Some(ApiProvider::Anthropic),
5871 &config,
5872 None,
5873 );
5874 assert_eq!(picker.stage, Stage::List);
5875
5876 picker.enter_key_entry();
5877 assert_eq!(picker.stage, Stage::KeyEntry);
5878
5879 assert!(matches!(
5880 picker.handle_key(key(KeyCode::Esc)),
5881 ViewAction::None
5882 ));
5883 assert_eq!(
5884 picker.stage,
5885 Stage::List,
5886 "Escape from key entry returns to the provider list"
5887 );
5888
5889 assert!(
5890 matches!(
5891 picker.handle_key(key(KeyCode::Esc)),
5892 ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
5893 ),
5894 "Escape from the list dismisses the picker"
5895 );
5896 }
5897
5898 #[test]
5899 fn setup_catalog_uses_setup_title() {
5900 let config = Config::default();
5901 let picker = ProviderPickerView::new_for_setup(ApiProvider::Deepseek, None, &config, None);
5902
5903 let rendered = render_text(&picker, 96, 20);
5904
5905 assert!(rendered.contains("Provider setup"));
5906 }
5907
5908 #[test]
5909 fn setup_catalog_key_entry_uses_setup_reopen_hint() {
5910 let config = Config::default();
5911 let picker = ProviderPickerView::new_for_setup(
5912 ApiProvider::Deepseek,
5913 Some(ApiProvider::Anthropic),
5914 &config,
5915 None,
5916 );
5917
5918 let rendered = render_text(&picker, 96, 20);
5919
5920 assert!(rendered.contains("API key"));
5921 assert!(rendered.contains("/setup provider"));
5922 assert!(!rendered.contains("re-open /provider."));
5923 }
5924
5925 #[test]
5926 fn default_provider_picker_keeps_provider_reopen_hint() {
5927 let config = Config::default();
5928 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
5929 move_to_provider(&mut picker, ApiProvider::Anthropic);
5930 picker.handle_key(key(KeyCode::Enter));
5931
5932 let rendered = render_text(&picker, 96, 20);
5933
5934 assert!(rendered.contains("API key"));
5935 assert!(rendered.contains("re-open /provider."));
5936 assert!(!rendered.contains("/setup provider"));
5937 }
5938
5939 #[test]
5940 fn setup_catalog_focuses_configured_provider_without_rekeying() {
5941 let config = Config {
5942 providers: Some(crate::config::ProvidersConfig {
5943 openai: crate::config::ProviderConfig {
5944 api_key: Some("openai-key".to_string()),
5945 ..Default::default()
5946 },
5947 ..Default::default()
5948 }),
5949 ..Config::default()
5950 };
5951 let picker = ProviderPickerView::new_for_setup(
5952 ApiProvider::Deepseek,
5953 Some(ApiProvider::Openai),
5954 &config,
5955 None,
5956 );
5957
5958 assert_eq!(picker.view, ProviderListView::Catalog);
5959 assert_eq!(picker.stage, Stage::List);
5960 assert_eq!(picker.selected_provider(), ApiProvider::Openai);
5961 }
5962
5963 #[test]
5964 fn new_for_key_entry_with_error_opens_prompt_and_renders_reason() {
5965 let config = Config::default();
5966 let picker = ProviderPickerView::new_for_key_entry_with_error(
5967 ApiProvider::Deepseek,
5968 ApiProvider::Openrouter,
5969 &config,
5970 None,
5971 "HTTP 401: unauthorized".to_string(),
5972 )
5973 .expect("OpenRouter has a picker row");
5974
5975 assert_eq!(picker.stage, Stage::KeyEntry);
5976 assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
5977 let rendered = render_text(&picker, 90, 14);
5978 assert!(rendered.contains("Verification failed: HTTP 401: unauthorized"));
5979 }
5980
5981 #[test]
5982 fn new_for_model_pick_after_validation_opens_model_stage() {
5983 let config = Config::default();
5984 let picker = ProviderPickerView::new_for_model_pick_after_validation(
5985 ApiProvider::Deepseek,
5986 ApiProvider::Openrouter,
5987 &config,
5988 None,
5989 "sk-validated".to_string(),
5990 None,
5991 )
5992 .expect("OpenRouter has a picker row");
5993
5994 assert_eq!(picker.stage, Stage::ModelPick);
5995 assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
5996 assert_eq!(picker.pending_api_key.as_deref(), Some("sk-validated"));
5997 assert!(!picker.model_options.is_empty());
5998 assert!(picker.selected_model.is_some());
5999 }
6000
6001 #[test]
6002 fn model_pick_enter_advances_to_confirm_and_confirm_emits_setup() {
6003 let config = Config::default();
6004 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
6005 ApiProvider::Deepseek,
6006 ApiProvider::Openrouter,
6007 &config,
6008 None,
6009 "sk-validated".to_string(),
6010 None,
6011 )
6012 .expect("OpenRouter has a picker row");
6013
6014 assert_eq!(picker.stage, Stage::ModelPick);
6015 let action = picker.handle_key(key(KeyCode::Enter));
6016 assert!(matches!(action, ViewAction::None));
6017 assert_eq!(picker.stage, Stage::Confirm);
6018
6019 let selected_model = picker
6020 .selected_model
6021 .clone()
6022 .expect("model selected on confirm");
6023 let action = picker.handle_key(key(KeyCode::Enter));
6024 match action {
6025 ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
6026 provider,
6027 provider_id,
6028 api_key,
6029 model,
6030 ..
6031 }) => {
6032 assert_eq!(provider, ApiProvider::Openrouter);
6033 assert_eq!(provider_id, None);
6034 assert_eq!(api_key, "sk-validated");
6035 assert_eq!(model, selected_model);
6036 }
6037 other => panic!("expected ProviderPickerSetupConfirmed, got {other:?}"),
6038 }
6039 }
6040
6041 #[test]
6042 fn exact_kimi_code_setup_asks_for_plan_and_emits_selected_context_window() {
6043 let config = Config {
6044 providers: Some(crate::config::ProvidersConfig {
6045 moonshot: crate::config::ProviderConfig {
6046 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
6047 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
6048 ..Default::default()
6049 },
6050 ..Default::default()
6051 }),
6052 ..Default::default()
6053 };
6054 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
6055 ApiProvider::Deepseek,
6056 ApiProvider::Moonshot,
6057 &config,
6058 None,
6059 "sk-kimi-plan".to_string(),
6060 None,
6061 )
6062 .expect("Moonshot has a picker row");
6063
6064 assert_eq!(picker.stage, Stage::ModelPick);
6065 assert!(matches!(
6066 picker.handle_key(key(KeyCode::Enter)),
6067 ViewAction::None
6068 ));
6069 assert_eq!(picker.stage, Stage::PlanTier);
6070 assert!(matches!(
6071 picker.handle_key(key(KeyCode::Char('2'))),
6072 ViewAction::None
6073 ));
6074 assert!(matches!(
6075 picker.handle_key(key(KeyCode::Enter)),
6076 ViewAction::None
6077 ));
6078 assert_eq!(picker.stage, Stage::Confirm);
6079 match picker.handle_key(key(KeyCode::Enter)) {
6080 ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
6081 context_window,
6082 model,
6083 ..
6084 }) => {
6085 assert_eq!(model, crate::config::KIMI_CODE_K3_MODEL);
6086 assert_eq!(context_window, Some(1_048_576));
6087 }
6088 other => panic!("expected Kimi Code setup confirmation, got {other:?}"),
6089 }
6090 }
6091
6092 #[test]
6093 fn model_pick_and_confirm_esc_backs_out_without_emitting() {
6094 let config = Config::default();
6095 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
6096 ApiProvider::Deepseek,
6097 ApiProvider::Openrouter,
6098 &config,
6099 None,
6100 "sk-validated".to_string(),
6101 None,
6102 )
6103 .expect("OpenRouter has a picker row");
6104
6105 picker.handle_key(key(KeyCode::Enter));
6106 assert_eq!(picker.stage, Stage::Confirm);
6107 assert!(matches!(
6108 picker.handle_key(key(KeyCode::Esc)),
6109 ViewAction::None
6110 ));
6111 assert_eq!(picker.stage, Stage::ModelPick);
6112
6113 assert!(matches!(
6114 picker.handle_key(key(KeyCode::Esc)),
6115 ViewAction::None
6116 ));
6117 assert_eq!(picker.stage, Stage::KeyEntry);
6118 assert_eq!(picker.api_key_input, "sk-validated");
6119 assert!(picker.pending_api_key.is_some());
6120 }
6121
6122 fn stepfun_config(base_url: Option<&str>) -> Config {
6123 Config {
6124 providers: Some(crate::config::ProvidersConfig {
6125 stepfun: crate::config::ProviderConfig {
6126 base_url: base_url.map(str::to_string),
6127 ..Default::default()
6128 },
6129 ..Default::default()
6130 }),
6131 ..Default::default()
6132 }
6133 }
6134
6135 /// #4526: StepFun's two billing tracks are two endpoints. Setup asks which
6136 /// one the key belongs to, and the choice reaches key entry as a pending —
6137 /// not yet persisted — endpoint.
6138 #[test]
6139 fn stepfun_setup_asks_for_billing_route_before_key_entry() {
6140 let config = stepfun_config(None);
6141 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6142 move_to_provider(&mut picker, ApiProvider::Stepfun);
6143
6144 assert!(matches!(
6145 picker.handle_key(key(KeyCode::Char('r'))),
6146 ViewAction::None
6147 ));
6148 assert_eq!(picker.stage, Stage::StepfunBillingRoute);
6149 assert_eq!(
6150 picker.stepfun_billing_route,
6151 StepfunBillingRoute::PayAsYouGo
6152 );
6153
6154 // The endpoints are the whole difference between the two tracks, so
6155 // both have to be legible at the narrow terminal size too.
6156 for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
6157 let rendered = render_text(&picker, w, h);
6158 assert!(
6159 rendered.contains(crate::config::DEFAULT_STEPFUN_BASE_URL)
6160 && rendered.contains(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL),
6161 "{w}x{h} must show both StepFun endpoints:\n{rendered}"
6162 );
6163 for (idx, line) in rendered.lines().enumerate() {
6164 assert!(
6165 crate::tui::ui_text::text_display_width(line) <= w as usize,
6166 "{w}x{h} billing-route line {idx} overflows: {line:?}"
6167 );
6168 }
6169 }
6170
6171 assert!(matches!(
6172 picker.handle_key(key(KeyCode::Char('2'))),
6173 ViewAction::None
6174 ));
6175 assert!(matches!(
6176 picker.handle_key(key(KeyCode::Enter)),
6177 ViewAction::None
6178 ));
6179 assert_eq!(picker.stage, Stage::KeyEntry);
6180 assert_eq!(
6181 picker.pending_base_url.as_deref(),
6182 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
6183 );
6184 }
6185
6186 /// The chosen endpoint rides on the key-submit event so the live check in
6187 /// `ui.rs` probes the Step Plan route, not the pay-as-you-go default.
6188 #[test]
6189 fn stepfun_plan_choice_travels_with_the_key_for_validation() {
6190 let config = stepfun_config(None);
6191 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6192 move_to_provider(&mut picker, ApiProvider::Stepfun);
6193 picker.handle_key(key(KeyCode::Char('r')));
6194 picker.handle_key(key(KeyCode::Char('2')));
6195 picker.handle_key(key(KeyCode::Enter));
6196 for c in "step-plan-key".chars() {
6197 picker.handle_key(key(KeyCode::Char(c)));
6198 }
6199
6200 match picker.handle_key(key(KeyCode::Enter)) {
6201 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
6202 provider,
6203 api_key,
6204 base_url,
6205 ..
6206 }) => {
6207 assert_eq!(provider, ApiProvider::Stepfun);
6208 assert_eq!(api_key, "step-plan-key");
6209 assert_eq!(
6210 base_url.as_deref(),
6211 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
6212 );
6213 }
6214 other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
6215 }
6216 }
6217
6218 /// Confirm carries exactly the validated endpoint, and nothing else about
6219 /// the route, so the handler writes only `[providers.stepfun] base_url`.
6220 #[test]
6221 fn stepfun_confirm_emits_only_the_validated_endpoint() {
6222 let config = stepfun_config(None);
6223 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
6224 ApiProvider::Deepseek,
6225 ApiProvider::Stepfun,
6226 &config,
6227 None,
6228 "step-plan-key".to_string(),
6229 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
6230 )
6231 .expect("StepFun has a picker row");
6232
6233 assert_eq!(picker.stage, Stage::ModelPick);
6234 picker.handle_key(key(KeyCode::Enter));
6235 assert_eq!(picker.stage, Stage::Confirm);
6236 match picker.handle_key(key(KeyCode::Enter)) {
6237 ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
6238 provider,
6239 base_url,
6240 context_window,
6241 ..
6242 }) => {
6243 assert_eq!(provider, ApiProvider::Stepfun);
6244 assert_eq!(
6245 base_url.as_deref(),
6246 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
6247 );
6248 assert_eq!(context_window, None);
6249 }
6250 other => panic!("expected ProviderPickerSetupConfirmed, got {other:?}"),
6251 }
6252 }
6253
6254 /// A hand-configured StepFun endpoint is a deliberate choice. The wizard
6255 /// skips the billing-route stage entirely and emits no endpoint, so the
6256 /// custom value is never silently rewritten (#4526).
6257 #[test]
6258 fn stepfun_custom_base_url_survives_the_wizard_untouched() {
6259 let custom = "https://stepfun.internal.example/v1";
6260 let config = stepfun_config(Some(custom));
6261 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6262 move_to_provider(&mut picker, ApiProvider::Stepfun);
6263 assert_eq!(picker.rows[picker.selected_idx].base_url, custom);
6264
6265 picker.handle_key(key(KeyCode::Char('r')));
6266 assert_eq!(picker.stage, Stage::KeyEntry);
6267 assert_eq!(picker.pending_base_url, None);
6268 assert_eq!(picker.rows[picker.selected_idx].base_url, custom);
6269
6270 for c in "custom-key".chars() {
6271 picker.handle_key(key(KeyCode::Char(c)));
6272 }
6273 match picker.handle_key(key(KeyCode::Enter)) {
6274 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
6275 base_url, ..
6276 }) => assert_eq!(base_url, None, "custom endpoint must not be rewritten"),
6277 other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
6278 }
6279 }
6280
6281 /// A StepFun route already on Step Plan re-opens preselected there rather
6282 /// than defaulting the user back onto pay-as-you-go.
6283 #[test]
6284 fn stepfun_plan_route_reopens_preselected() {
6285 let config = stepfun_config(Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL));
6286 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6287 move_to_provider(&mut picker, ApiProvider::Stepfun);
6288 picker.handle_key(key(KeyCode::Char('r')));
6289
6290 assert_eq!(picker.stage, Stage::StepfunBillingRoute);
6291 assert_eq!(picker.stepfun_billing_route, StepfunBillingRoute::StepPlan);
6292 }
6293
6294 /// #4526: OpenCode Go (subscription allowance) and OpenCode Zen
6295 /// (pay-as-you-go) are separate billing tracks and must not present as the
6296 /// same generic meter.
6297 #[test]
6298 fn opencode_go_and_zen_read_as_distinct_billing_tracks() {
6299 let go = usage_meter_for(ApiProvider::OpencodeGo);
6300 let zen = usage_meter_for(ApiProvider::OpencodeZen);
6301 assert_ne!(go, zen);
6302 assert!(go.contains("subscription"), "Go label was {go:?}");
6303 assert!(zen.contains("pay-as-you-go"), "Zen label was {zen:?}");
6304 assert_ne!(go, usage_meter_for(ApiProvider::Openrouter));
6305
6306 // Go never reports catalog token prices: its allowance is not spend.
6307 assert_eq!(
6308 pricing_label(
6309 ApiProvider::OpencodeGo,
6310 Some(&PricingSku::Token {
6311 input_per_mtok: Some(1.0),
6312 output_per_mtok: Some(2.0),
6313 }),
6314 ),
6315 go
6316 );
6317 }
6318
6319 #[test]
6320 fn guided_flow_stages_render_at_80x24_and_120x32() {
6321 let config = Config::default();
6322 let model_pick = ProviderPickerView::new_for_model_pick_after_validation(
6323 ApiProvider::Deepseek,
6324 ApiProvider::Openrouter,
6325 &config,
6326 None,
6327 "sk-validated-key".to_string(),
6328 None,
6329 )
6330 .expect("OpenRouter has a picker row");
6331 let mut confirm = ProviderPickerView::new_for_model_pick_after_validation(
6332 ApiProvider::Deepseek,
6333 ApiProvider::Openrouter,
6334 &config,
6335 None,
6336 "sk-validated-key".to_string(),
6337 None,
6338 )
6339 .expect("OpenRouter has a picker row");
6340 confirm.handle_key(key(KeyCode::Enter));
6341 assert_eq!(confirm.stage, Stage::Confirm);
6342
6343 for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
6344 let model_text = render_text(&model_pick, w, h);
6345 assert!(
6346 model_text.contains("Default model") || model_text.contains("default model"),
6347 "{w}x{h} model pick missing title:\n{model_text}"
6348 );
6349 assert!(
6350 model_text.contains("continue") || model_text.contains("Enter"),
6351 "{w}x{h} model pick missing continue affordance:\n{model_text}"
6352 );
6353 for (idx, line) in model_text.lines().enumerate() {
6354 assert!(
6355 crate::tui::ui_text::text_display_width(line) <= w as usize,
6356 "{w}x{h} model pick line {idx} overflows: {line:?}"
6357 );
6358 }
6359
6360 let confirm_text = render_text(&confirm, w, h);
6361 assert!(
6362 confirm_text.contains("Confirm"),
6363 "{w}x{h} confirm missing title:\n{confirm_text}"
6364 );
6365 assert!(
6366 confirm_text.contains("Provider:") || confirm_text.contains("OpenRouter"),
6367 "{w}x{h} confirm missing provider summary:\n{confirm_text}"
6368 );
6369 assert!(
6370 confirm_text.contains("Model:") || confirm_text.contains("model"),
6371 "{w}x{h} confirm missing model summary:\n{confirm_text}"
6372 );
6373 // Masked key only — never the raw secret.
6374 assert!(
6375 !confirm_text.contains("sk-validated-key"),
6376 "{w}x{h} confirm leaked raw key:\n{confirm_text}"
6377 );
6378 for (idx, line) in confirm_text.lines().enumerate() {
6379 assert!(
6380 crate::tui::ui_text::text_display_width(line) <= w as usize,
6381 "{w}x{h} confirm line {idx} overflows: {line:?}"
6382 );
6383 }
6384 }
6385 }
6386
6387 #[test]
6388 fn configured_provider_can_reenter_key_entry_with_r() {
6389 let config = Config {
6390 providers: Some(crate::config::ProvidersConfig {
6391 xiaomi_mimo: crate::config::ProviderConfig {
6392 api_key: Some("mimo-key".to_string()),
6393 ..Default::default()
6394 },
6395 ..Default::default()
6396 }),
6397 ..Config::default()
6398 };
6399 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6400 move_to_provider(&mut picker, ApiProvider::XiaomiMimo);
6401
6402 let action = picker.handle_key(key(KeyCode::Char('r')));
6403
6404 assert!(matches!(action, ViewAction::None));
6405 assert_eq!(picker.stage, Stage::KeyEntry);
6406 assert!(picker.api_key_input.is_empty());
6407 }
6408
6409 #[test]
6410 fn configured_api_key_editors_acknowledge_saved_credentials_across_providers() {
6411 for (provider, config, secret) in [
6412 (
6413 ApiProvider::Zai,
6414 Config {
6415 providers: Some(crate::config::ProvidersConfig {
6416 zai: crate::config::ProviderConfig {
6417 api_key: Some("stored-zai-key".to_string()),
6418 ..Default::default()
6419 },
6420 ..Default::default()
6421 }),
6422 ..Config::default()
6423 },
6424 "stored-zai-key",
6425 ),
6426 (
6427 ApiProvider::Openrouter,
6428 Config {
6429 providers: Some(crate::config::ProvidersConfig {
6430 openrouter: crate::config::ProviderConfig {
6431 api_key: Some("stored-openrouter-key".to_string()),
6432 ..Default::default()
6433 },
6434 ..Default::default()
6435 }),
6436 ..Config::default()
6437 },
6438 "stored-openrouter-key",
6439 ),
6440 ] {
6441 let mut picker = ProviderPickerView::new(provider, &config);
6442 move_to_provider(&mut picker, provider);
6443 picker.handle_key(key(KeyCode::Char('r')));
6444
6445 let rendered = render_text(&picker, 100, 20);
6446
6447 assert!(
6448 rendered.contains("Saved credential configured"),
6449 "{provider:?}:\n{rendered}"
6450 );
6451 assert!(rendered.contains("stored credential"), "{rendered}");
6452 assert!(rendered.contains("replace saved key"), "{rendered}");
6453 assert!(rendered.contains("keep current key"), "{rendered}");
6454 assert!(!rendered.contains("paste key here"), "{rendered}");
6455 assert!(!rendered.contains(secret), "{rendered}");
6456 }
6457 }
6458
6459 #[test]
6460 fn ctrl_r_does_not_trigger_key_entry() {
6461 let config = Config::default();
6462 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6463
6464 let action = picker.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
6465
6466 assert!(matches!(action, ViewAction::None));
6467 assert_eq!(picker.stage, Stage::List);
6468 }
6469
6470 #[test]
6471 fn configured_provider_footer_mentions_edit_key() {
6472 let config = Config {
6473 api_key: Some("existing-deepseek-key".to_string()),
6474 ..Config::default()
6475 };
6476 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6477
6478 let rendered = render_text(&picker, 80, 14);
6479
6480 assert!(rendered.contains("Enter"), "rendered: {rendered}");
6481 assert!(rendered.contains("apply"));
6482 assert!(rendered.contains("edit key"));
6483 }
6484
6485 #[test]
6486 fn key_entry_enter_submits_after_typing() {
6487 let config = Config::default();
6488 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6489 // Navigate to Novita and trigger key entry.
6490 move_to_provider(&mut picker, ApiProvider::Novita);
6491 picker.handle_key(key(KeyCode::Enter));
6492 assert_eq!(picker.stage, Stage::KeyEntry);
6493 for c in "novita-key".chars() {
6494 picker.handle_key(key(KeyCode::Char(c)));
6495 }
6496 let action = picker.handle_key(key(KeyCode::Enter));
6497 match action {
6498 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
6499 provider,
6500 provider_id,
6501 api_key,
6502 base_url,
6503 }) => {
6504 assert_eq!(provider, ApiProvider::Novita);
6505 assert_eq!(provider_id, None);
6506 assert_eq!(api_key, "novita-key");
6507 assert_eq!(base_url, None);
6508 }
6509 other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
6510 }
6511 }
6512
6513 #[test]
6514 fn openai_codex_key_entry_is_oauth_only() {
6515 let config = Config::default();
6516 let mut picker = ProviderPickerView::new_for_missing_auth(
6517 ApiProvider::Deepseek,
6518 ApiProvider::OpenaiCodex,
6519 &config,
6520 None,
6521 )
6522 .expect("OpenAI Codex has a picker row");
6523 assert_eq!(picker.stage, Stage::KeyEntry);
6524
6525 let rendered = render_text(&picker, 96, 20);
6526 assert!(rendered.contains("OAuth login"), "{rendered}");
6527 assert!(rendered.contains("no token is stored here"), "{rendered}");
6528 assert!(!rendered.contains("save & switch"));
6529 assert!(!rendered.contains("(paste key here)"));
6530 assert!(!rendered.contains("Credentials:"));
6531
6532 assert!(picker.handle_paste("codex-token"));
6533 for c in "codex-token".chars() {
6534 picker.handle_key(key(KeyCode::Char(c)));
6535 }
6536 assert!(picker.api_key_input.is_empty());
6537 assert!(matches!(
6538 picker.handle_key(key(KeyCode::Enter)),
6539 ViewAction::None
6540 ));
6541 assert_eq!(picker.stage, Stage::ExternalConsentChoice);
6542 let choices = render_text(&picker, 100, 20);
6543 assert!(choices.contains("Disabled (default)"), "{choices}");
6544 assert!(choices.contains("Read-only"), "{choices}");
6545 assert!(choices.contains("Managed (unavailable)"), "{choices}");
6546
6547 picker.handle_key(key(KeyCode::Char('2')));
6548 picker.handle_key(key(KeyCode::Enter));
6549 assert_eq!(picker.stage, Stage::ExternalConsentConfirm);
6550 let confirm = render_text(&picker, 120, 22);
6551 assert!(confirm.contains("Owning CLI: Codex CLI"), "{confirm}");
6552 assert!(confirm.contains("Exact resolved path:"), "{confirm}");
6553 assert!(confirm.contains("no refresh, identity-provider or discovery requests"));
6554 assert!(confirm.contains("normal requests to the selected provider"));
6555 assert!(confirm.contains("external-revoke --provider openai-codex"));
6556 assert!(matches!(
6557 picker.handle_key(key(KeyCode::Enter)),
6558 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentConfirmed {
6559 provider: ApiProvider::OpenaiCodex,
6560 consent_provider: codewhale_config::ProviderKind::OpenaiCodex,
6561 source: codewhale_config::ExternalCredentialSource::CodexCli,
6562 ..
6563 })
6564 ));
6565 }
6566
6567 #[test]
6568 fn external_consent_surface_uses_the_selected_locale() {
6569 let config = Config::default();
6570 let mut picker = ProviderPickerView::new_for_missing_auth(
6571 ApiProvider::Deepseek,
6572 ApiProvider::OpenaiCodex,
6573 &config,
6574 None,
6575 )
6576 .expect("OpenAI Codex has a picker row")
6577 .with_locale(crate::localization::Locale::ZhHans);
6578
6579 picker.handle_key(key(KeyCode::Enter));
6580 let choices = render_text(&picker, 100, 20);
6581 let compact = choices
6582 .chars()
6583 .filter(|ch| !ch.is_whitespace())
6584 .collect::<String>();
6585 assert!(compact.contains("外部凭据访问"), "{choices}");
6586 assert!(compact.contains("禁用(默认)"), "{choices}");
6587 assert!(compact.contains("托管(不可用)"), "{choices}");
6588 }
6589
6590 #[test]
6591 fn xai_auth_choice_keeps_api_key_device_oauth_and_external_reuse_distinct() {
6592 let config = Config::default();
6593 let mut picker = ProviderPickerView::new_for_missing_auth(
6594 ApiProvider::Deepseek,
6595 ApiProvider::Xai,
6596 &config,
6597 None,
6598 )
6599 .expect("xAI has a picker row");
6600 assert_eq!(picker.stage, Stage::XaiAuthChoice);
6601
6602 let rendered = render_text(&picker, 96, 20);
6603 assert!(rendered.contains("xAI API key"));
6604 assert!(rendered.contains("Native device OAuth"));
6605 assert!(rendered.contains("Codewhale-owned storage"));
6606 picker.handle_key(key(KeyCode::Char('2')));
6607 assert!(matches!(
6608 picker.handle_key(key(KeyCode::Enter)),
6609 ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
6610 ));
6611
6612 let mut external = ProviderPickerView::new_for_missing_auth(
6613 ApiProvider::Deepseek,
6614 ApiProvider::Xai,
6615 &config,
6616 None,
6617 )
6618 .expect("xAI has a picker row");
6619 assert!(matches!(
6620 external.handle_key(key(KeyCode::Char('e'))),
6621 ViewAction::None
6622 ));
6623 assert_eq!(external.stage, Stage::ExternalConsentChoice);
6624 let rendered = render_text(&external, 100, 20);
6625 assert!(rendered.contains("Managed (unavailable)"), "{rendered}");
6626 }
6627
6628 #[test]
6629 fn xai_auth_choice_uses_the_selected_locale() {
6630 let config = Config::default();
6631 let picker = ProviderPickerView::new_for_missing_auth(
6632 ApiProvider::Deepseek,
6633 ApiProvider::Xai,
6634 &config,
6635 None,
6636 )
6637 .expect("xAI has a picker row")
6638 .with_locale(crate::localization::Locale::ZhHans);
6639
6640 let rendered = render_text(&picker, 100, 24);
6641 let compact = rendered
6642 .chars()
6643 .filter(|ch| !ch.is_whitespace())
6644 .collect::<String>();
6645 for translated in [
6646 "xAI身份验证",
6647 "请选择一个明确的凭据来源",
6648 "xAIAPI密钥",
6649 "原生设备OAuth",
6650 ] {
6651 assert!(compact.contains(translated), "{translated}: {rendered}");
6652 }
6653 assert!(!rendered.contains("Choose one explicit credential source"));
6654 assert!(!rendered.contains("Native device OAuth"));
6655 }
6656
6657 #[test]
6658 fn xai_auth_status_distinguishes_oauth_from_api_key_auth() {
6659 let oauth_config = crate::config::ProviderConfig {
6660 auth_mode: Some("oauth".to_string()),
6661 ..Default::default()
6662 };
6663 assert_eq!(
6664 xai_oauth_status(Some(&oauth_config), false),
6665 Some(ProviderAuthStatus::OAuthMissing)
6666 );
6667 assert_eq!(
6668 xai_oauth_status(Some(&oauth_config), true),
6669 Some(ProviderAuthStatus::OAuthReady)
6670 );
6671 assert_eq!(xai_oauth_status(None, true), None);
6672 assert_eq!(xai_oauth_status(None, false), None);
6673
6674 let fallback_key = crate::config::ProviderConfig {
6675 auth_mode: Some("oauth".to_string()),
6676 api_key: Some("xai-api-key".to_string()),
6677 ..Default::default()
6678 };
6679 assert_eq!(
6680 xai_oauth_status(Some(&fallback_key), false),
6681 Some(ProviderAuthStatus::Configured)
6682 );
6683 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
6684 let placeholder = crate::config::ProviderConfig {
6685 auth_mode: Some("oauth".to_string()),
6686 api_key: Some(sentinel.to_string()),
6687 ..Default::default()
6688 };
6689 assert_eq!(
6690 xai_oauth_status(Some(&placeholder), false),
6691 Some(ProviderAuthStatus::OAuthMissing)
6692 );
6693 }
6694 }
6695
6696 #[test]
6697 fn inactive_external_consents_are_visible_without_io_and_never_enter_routing_inventory() {
6698 let _env = crate::test_support::lock_test_env();
6699 let temp = tempfile::tempdir().expect("external consent fixtures");
6700 let codex_path = temp.path().join("codex-auth.json");
6701 let grok_path = temp.path().join("grok-auth.json");
6702 let codex_raw = "codex-external-file-must-not-be-read";
6703 let grok_raw = "grok-external-file-must-not-be-read";
6704 std::fs::write(&codex_path, codex_raw).expect("write Codex trap");
6705 std::fs::write(&grok_path, grok_raw).expect("write Grok trap");
6706 let owned_home = temp.path().join("codewhale-owned");
6707
6708 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
6709 let _codex_path =
6710 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
6711 let _grok_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
6712 let _codex_access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
6713 let _legacy_codex_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
6714 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
6715 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
6716 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
6717
6718 let config = Config {
6719 provider: Some(ApiProvider::Deepseek.as_str().to_string()),
6720 providers: Some(crate::config::ProvidersConfig {
6721 openai_codex: crate::config::ProviderConfig {
6722 auth_mode: Some("oauth".to_string()),
6723 external_credentials: Some(
6724 codewhale_config::ExternalCredentialConsentToml::read_only(
6725 codewhale_config::ProviderKind::OpenaiCodex,
6726 codewhale_config::ExternalCredentialSource::CodexCli,
6727 codex_path.clone(),
6728 ),
6729 ),
6730 ..Default::default()
6731 },
6732 xai: crate::config::ProviderConfig {
6733 auth_mode: Some("oauth".to_string()),
6734 external_credentials: Some(
6735 codewhale_config::ExternalCredentialConsentToml::read_only(
6736 codewhale_config::ProviderKind::Xai,
6737 codewhale_config::ExternalCredentialSource::GrokCli,
6738 grok_path.clone(),
6739 ),
6740 ),
6741 ..Default::default()
6742 },
6743 ..Default::default()
6744 }),
6745 ..Default::default()
6746 };
6747
6748 crate::external_credentials::reset_side_effect_trap();
6749 assert!(!has_api_key_for(&config, ApiProvider::OpenaiCodex));
6750 assert!(!has_api_key_for(&config, ApiProvider::Xai));
6751
6752 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6753 for provider in [ApiProvider::OpenaiCodex, ApiProvider::Xai] {
6754 let index = picker
6755 .rows
6756 .iter()
6757 .position(|row| row.provider == provider)
6758 .expect("consented provider row");
6759 let row = &picker.rows[index];
6760 assert_eq!(row.credential_state, CredentialState::ExternalConsent);
6761 assert_eq!(row.auth_status, ProviderAuthStatus::OAuthConsented);
6762 let structural = row
6763 .external_credential_status
6764 .as_ref()
6765 .expect("external status");
6766 assert_eq!(structural.access.as_str(), "read_only");
6767 assert_eq!(structural.route_state, "dormant");
6768 assert!(structural.revoke_command.contains(provider.as_str()));
6769 assert_eq!(
6770 row.readiness,
6771 ResolvedProviderReadiness::ExternalConsentPendingSelection
6772 );
6773 assert!(!row.readiness.can_attempt());
6774 picker.selected_idx = index;
6775 let visible = render_text(&picker, 140, 32);
6776 assert!(visible.contains("External: access=read_only"), "{visible}");
6777 assert!(visible.contains("Owner/path:"), "{visible}");
6778 assert!(
6779 visible.contains("revoke: codewhale auth external-revoke"),
6780 "{visible}"
6781 );
6782 assert!(
6783 picker.selected_has_key(),
6784 "selecting {provider:?} should activate the consented route before checking it"
6785 );
6786 assert!(matches!(
6787 picker.handle_key(key(KeyCode::Enter)),
6788 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
6789 provider: selected,
6790 ..
6791 }) if selected == provider
6792 ));
6793 }
6794 assert!(matches!(
6795 picker.handle_key(key(KeyCode::Char('x'))),
6796 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
6797 provider: ApiProvider::Xai
6798 })
6799 ));
6800
6801 let inventory = crate::model_inventory::ModelInventory::from_config(&config);
6802 assert!(
6803 inventory.candidates.iter().all(|candidate| !matches!(
6804 candidate.provider,
6805 ApiProvider::OpenaiCodex | ApiProvider::Xai
6806 )),
6807 "dormant external-only routes must not reach auto-routing inventory"
6808 );
6809 assert_eq!(
6810 crate::route_billing::for_route(&config, ApiProvider::Xai),
6811 crate::route_billing::BillingPresentation::Metered
6812 );
6813 assert_eq!(
6814 crate::external_credentials::side_effect_trap_counts(),
6815 (0, 0),
6816 "picker, readiness, billing, and model inventory must not inspect inactive external files"
6817 );
6818 assert_eq!(
6819 std::fs::read_to_string(&codex_path).expect("Codex trap unchanged"),
6820 codex_raw
6821 );
6822 assert_eq!(
6823 std::fs::read_to_string(&grok_path).expect("Grok trap unchanged"),
6824 grok_raw
6825 );
6826 assert!(!owned_home.join("credentials/xai-auth.json").exists());
6827 }
6828
6829 #[test]
6830 fn kimi_cli_token_is_never_auto_enabled_without_explicit_legacy_auth_mode() {
6831 let _env = crate::test_support::lock_test_env();
6832 let temp = tempfile::tempdir().expect("Kimi import fixture root");
6833 let kimi_home = temp.path().join("kimi-code");
6834 std::fs::create_dir_all(kimi_home.join("credentials"))
6835 .expect("Kimi import credential directory");
6836 let expires_at = std::time::SystemTime::now()
6837 .duration_since(std::time::UNIX_EPOCH)
6838 .expect("clock after epoch")
6839 .as_secs_f64()
6840 + 3600.0;
6841 std::fs::write(
6842 kimi_home.join("credentials/kimi-code.json"),
6843 serde_json::json!({
6844 "access_token": "unexpired-user-owned-token",
6845 "refresh_token": "must-not-be-used",
6846 "expires_at": expires_at,
6847 })
6848 .to_string(),
6849 )
6850 .expect("write Kimi import fixture");
6851 let _kimi_home = crate::test_support::EnvVarGuard::set(
6852 "KIMI_CODE_HOME",
6853 kimi_home.to_str().expect("utf8 path"),
6854 );
6855 let _moonshot_key = crate::test_support::EnvVarGuard::remove("MOONSHOT_API_KEY");
6856 let _kimi_key = crate::test_support::EnvVarGuard::remove("KIMI_API_KEY");
6857
6858 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &Config::default());
6859 move_to_provider(&mut picker, ApiProvider::Moonshot);
6860 let row = &picker.rows[picker.selected_idx];
6861 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
6862 assert_eq!(row.credential_state, CredentialState::MissingKey);
6863
6864 assert!(matches!(
6865 picker.handle_key(key(KeyCode::Enter)),
6866 ViewAction::None
6867 ));
6868 assert_eq!(
6869 picker.stage,
6870 Stage::KeyEntry,
6871 "a stray Kimi CLI credential must lead to API-key setup, not import activation"
6872 );
6873 }
6874
6875 #[test]
6876 fn explicit_legacy_kimi_import_is_unavailable_and_routes_to_api_key_setup() {
6877 let _env = crate::test_support::lock_test_env();
6878 let temp = tempfile::tempdir().expect("Kimi import fixture root");
6879 let kimi_home = temp.path().join("kimi-code");
6880 std::fs::create_dir_all(kimi_home.join("credentials"))
6881 .expect("Kimi import credential directory");
6882 let expires_at = std::time::SystemTime::now()
6883 .duration_since(std::time::UNIX_EPOCH)
6884 .expect("clock after epoch")
6885 .as_secs_f64()
6886 + 3600.0;
6887 std::fs::write(
6888 kimi_home.join("credentials/kimi-code.json"),
6889 serde_json::json!({
6890 "access_token": "unexpired-user-owned-token",
6891 "refresh_token": "must-not-be-used",
6892 "expires_at": expires_at,
6893 })
6894 .to_string(),
6895 )
6896 .expect("write Kimi import fixture");
6897 let _kimi_home = crate::test_support::EnvVarGuard::set(
6898 "KIMI_CODE_HOME",
6899 kimi_home.to_str().expect("utf8 path"),
6900 );
6901 let config = Config {
6902 providers: Some(crate::config::ProvidersConfig {
6903 moonshot: crate::config::ProviderConfig {
6904 auth_mode: Some("kimi_oauth".to_string()),
6905 ..Default::default()
6906 },
6907 ..Default::default()
6908 }),
6909 ..Default::default()
6910 };
6911
6912 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6913 move_to_provider(&mut picker, ApiProvider::Moonshot);
6914 let row = &picker.rows[picker.selected_idx];
6915 assert_eq!(
6916 row.auth_status,
6917 ProviderAuthStatus::ImportedTokenUnavailable
6918 );
6919 assert_eq!(row.credential_state, CredentialState::MissingKey);
6920 assert_eq!(row.base_url, crate::config::DEFAULT_KIMI_CODE_BASE_URL);
6921 assert_eq!(
6922 row.default_route.logical_model,
6923 crate::config::DEFAULT_KIMI_CODE_MODEL
6924 );
6925 assert_eq!(row.usage_meter, "usage: Kimi API key required");
6926 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
6927 assert!(matches!(
6928 picker.handle_key(key(KeyCode::Enter)),
6929 ViewAction::None
6930 ));
6931 assert_eq!(picker.stage, Stage::KeyEntry);
6932 }
6933
6934 #[test]
6935 fn key_entry_esc_returns_to_list_without_emitting() {
6936 let config = Config::default();
6937 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6938 move_to_provider(&mut picker, ApiProvider::Openrouter);
6939 picker.handle_key(key(KeyCode::Enter));
6940 assert_eq!(picker.stage, Stage::KeyEntry);
6941 picker.handle_key(key(KeyCode::Char('a')));
6942 let action = picker.handle_key(key(KeyCode::Esc));
6943 assert!(matches!(action, ViewAction::None));
6944 assert_eq!(picker.stage, Stage::List);
6945 assert!(picker.api_key_input.is_empty());
6946 }
6947
6948 #[test]
6949 fn list_esc_emits_dismiss_memory() {
6950 let config = Config::default();
6951 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6952 let action = picker.handle_key(key(KeyCode::Esc));
6953 assert!(matches!(
6954 action,
6955 ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
6956 ));
6957 }
6958
6959 #[test]
6960 fn key_entry_strips_whitespace_chars() {
6961 let config = Config::default();
6962 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6963 move_to_provider(&mut picker, ApiProvider::Openrouter);
6964 picker.handle_key(key(KeyCode::Enter));
6965 assert_eq!(picker.stage, Stage::KeyEntry);
6966 for c in "abc def".chars() {
6967 picker.handle_key(key(KeyCode::Char(c)));
6968 }
6969 assert_eq!(picker.api_key_input, "abcdef");
6970 }
6971
6972 #[test]
6973 fn small_list_render_keeps_selected_provider_visible_after_down_navigation() {
6974 let config = Config::default();
6975 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6976 move_to_provider(&mut picker, ApiProvider::Ollama);
6977
6978 let rendered = render_text(&picker, 80, 12);
6979
6980 assert!(rendered.contains("Ollama"));
6981 assert!(!rendered.contains("DeepSeek *"));
6982 }
6983
6984 #[test]
6985 fn small_list_render_keeps_initial_active_provider_visible() {
6986 let config = Config::default();
6987 let picker = ProviderPickerView::new(ApiProvider::Ollama, &config);
6988
6989 let rendered = render_text(&picker, 80, 12);
6990
6991 assert!(rendered.contains("Ollama *"));
6992 }
6993
6994 #[test]
6995 fn tall_catalog_render_shows_selected_provider_details() {
6996 let config = Config::default();
6997 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
6998 // "All providers" means the full catalog (#3830), not just configured.
6999 picker.toggle_view();
7000
7001 let rendered = render_text(&picker, 80, 23);
7002
7003 assert!(rendered.contains("DeepSeek *"));
7004 assert!(rendered.contains("Details"));
7005 assert!(rendered.contains("Route:"));
7006 }
7007
7008 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires every
7009 /// overlay to remain readable and fully operable at.
7010 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
7011
7012 #[test]
7013 fn provider_picker_is_usable_and_opaque_at_blocker_sizes() {
7014 use crate::tui::views::ViewStack;
7015 // Provider display names contain capital X/Q (Xiaomi MiMo, Qianfan), so
7016 // use a glyph that can never appear in the modal content as the
7017 // bleed-through sentinel.
7018 const SENTINEL: &str = "\u{2592}"; // ▒
7019 let config = Config::default();
7020 // Make the first provider in the sorted list active so its highlighted
7021 // row sits at the top of the list, never on the vertical center cell
7022 // that must read as the opaque modal ink.
7023 let active = ProviderPickerView::new(ApiProvider::Deepseek, &config).rows[0].provider;
7024
7025 for (w, h) in BLOCKER_SIZES {
7026 let area = Rect::new(0, 0, w, h);
7027 let mut buf = Buffer::empty(area);
7028 for y in 0..h {
7029 for x in 0..w {
7030 buf[(x, y)].set_symbol(SENTINEL);
7031 }
7032 }
7033 // Render through the ViewStack so the shared opaque backdrop is
7034 // painted exactly as it is in production.
7035 let mut stack = ViewStack::new();
7036 stack.push(ProviderPickerView::new(active, &config));
7037 stack.render(area, &mut buf);
7038
7039 let rows: Vec<String> = (0..h)
7040 .map(|y| {
7041 (0..w)
7042 .map(|x| buf[(x, y)].symbol().to_string())
7043 .collect::<String>()
7044 })
7045 .collect();
7046 let text = rows.join("\n");
7047
7048 // Footer keeps every action (it wraps instead of clipping).
7049 for label in ["move", "jump", "edit key", "models", "cancel"] {
7050 assert!(text.contains(label), "{w}x{h}: missing '{label}' hint");
7051 }
7052 // The Enter action label is dynamic (apply vs set key); one shows.
7053 assert!(
7054 text.contains("apply") || text.contains("set key"),
7055 "{w}x{h}: missing Enter action label"
7056 );
7057 // Composited frame is fully opaque: no sentinel survives and the
7058 // center cell carries the modal ink background.
7059 assert!(
7060 !text.contains(SENTINEL),
7061 "{w}x{h}: background bleed-through into modal surface"
7062 );
7063 assert_eq!(
7064 buf[(w / 2, h / 2)].bg,
7065 palette::WHALE_BG,
7066 "{w}x{h}: modal interior must be opaque"
7067 );
7068 // No row exceeds the frame width (no horizontal overflow).
7069 for (y, row) in rows.iter().enumerate() {
7070 assert!(
7071 unicode_width::UnicodeWidthStr::width(row.trim_end()) <= w as usize,
7072 "{w}x{h}: row {y} overflows width: {row:?}"
7073 );
7074 }
7075 }
7076 }
7077
7078 #[test]
7079 fn selected_provider_row_uses_strong_highlight() {
7080 let config = Config::default();
7081 let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
7082 let area = Rect::new(0, 0, 80, 20);
7083 let mut buf = Buffer::empty(area);
7084
7085 picker.render(area, &mut buf);
7086
7087 let highlighted_cells = area
7088 .positions()
7089 .filter(|position| {
7090 let cell = &buf[*position];
7091 cell.bg == palette::SELECTION_BG
7092 })
7093 .count();
7094 assert!(
7095 highlighted_cells >= 32,
7096 "selected provider row should use a visible continuous highlight"
7097 );
7098 }
7099
7100 #[test]
7101 fn search_footer_shows_two_stage_esc_as_a_single_hint() {
7102 let config = Config::default();
7103 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
7104 picker.query = "deep".to_string();
7105 let area = Rect::new(0, 0, 100, 24);
7106 let mut buf = Buffer::empty(area);
7107
7108 picker.render(area, &mut buf);
7109
7110 let text = area
7111 .positions()
7112 .map(|position| buf[position].symbol())
7113 .collect::<String>();
7114 // The key appears once, with both stages spelled out in its label.
7115 assert_eq!(
7116 text.matches(" Esc ").count(),
7117 1,
7118 "search footer must not duplicate the Esc key: {text}"
7119 );
7120 assert!(text.contains("clear / cancel"), "{text}");
7121 }
7122
7123 #[test]
7124 fn esc_reports_browsing_context_and_reopen_restores_it() {
7125 let config = Config::default();
7126 let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
7127 // Browse full catalog and move highlight.
7128 picker.handle_key(key(KeyCode::Char('a')));
7129 picker.handle_key(key(KeyCode::Down));
7130 let remembered_id = picker.rows[picker.selected_idx].provider_id.clone();
7131 let action = picker.handle_key(key(KeyCode::Esc));
7132 let ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed {
7133 catalog_view,
7134 selected_provider_id,
7135 }) = action
7136 else {
7137 panic!("expected ProviderPickerDismissed");
7138 };
7139 assert!(catalog_view);
7140 assert_eq!(
7141 selected_provider_id.as_deref(),
7142 Some(remembered_id.as_str())
7143 );
7144
7145 let memory = crate::tui::app::ProviderPickerMemory {
7146 catalog_view,
7147 selected_provider_id,
7148 };
7149 let reopened = ProviderPickerView::new_with_runtime_status_and_memory(
7150 ApiProvider::Deepseek,
7151 &config,
7152 None,
7153 Some(&memory),
7154 );
7155 assert_eq!(reopened.view, ProviderListView::Catalog);
7156 assert_eq!(
7157 reopened.rows[reopened.selected_idx].provider_id,
7158 remembered_id
7159 );
7160 }
7161 }
7162
7162 lines RUST