返回 CodeWhale
provider_routes.rs
根目录 / crates / tui / src / tui / ui / provider_routes.rs
1 //! Provider and route plumbing reached from the UI: switching providers,
2 //! MCP import/reload, balance and catalog fetches, and onboarding's
3 //! provider/trust steps.
4 //!
5 //! Moved verbatim out of `ui.rs`.
6
7 use super::*;
8
9 pub(crate) fn complete_trust_directory_onboarding(
10 app: &mut App,
11 config: &Config,
12 ) -> Result<(), String> {
13 let enter_hint = app.tr(MessageId::OnboardTrustEnterHint).into_owned();
14 onboarding::mark_trusted(&app.workspace).map_err(|err| err.to_string())?;
15 app.trust_mode = true;
16 // `rebind`, not `new`: trusting the directory can add project hooks, but
17 // it does not start a new session. Hooks that already fired this session
18 // reported a `DEEPSEEK_SESSION_ID`, and it has to keep meaning the same
19 // session afterwards.
20 app.hooks = app.hooks.rebind(
21 crate::hooks::HooksConfig::load_with_project_and_plugins(
22 config.hooks_config(),
23 &app.workspace,
24 Some(app.plugin_registry.as_ref()),
25 ),
26 app.workspace.clone(),
27 );
28 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
29 app.status_message = None;
30 app.status_toasts.retain(|toast| toast.text != enter_hint);
31 advance_after_trust_directory_choice(app);
32 Ok(())
33 }
34
35 /// Continue past the trust step without recording workspace trust.
36 ///
37 /// Tools and hooks stay restricted for this session; the next launch will
38 /// re-prompt until the user trusts (or uses an explicit trust command).
39 pub(crate) fn continue_without_trusting_directory(app: &mut App) {
40 app.trust_mode = false;
41 app.status_message = Some(app.tr(MessageId::OnboardTrustUntrustedNotice).to_string());
42 advance_after_trust_directory_choice(app);
43 }
44
45 pub(crate) fn advance_after_trust_directory_choice(app: &mut App) {
46 if app.onboarding_workspace_trust_gate {
47 app.onboarding_workspace_trust_gate = false;
48 app.onboarding = OnboardingState::None;
49 } else {
50 // Both a first run and missing-key recovery end on the ready screen;
51 // a trust-gate-only launch (already onboarded) exits directly above.
52 app.onboarding = OnboardingState::Ready;
53 }
54 }
55
56 /// Decide the onboarding route for one key press.
57 ///
58 /// Two invariants this encodes, both regressions reported in #4763:
59 /// Ctrl+C quits from *any* onboarding state — a modal on the stack must not
60 /// swallow it — and Escape is never intercepted on the picker's behalf, so
61 /// the picker can back out one stage at a time instead of the shell popping
62 /// the whole modal from a key/OAuth sub-stage.
63 pub(crate) fn onboarding_key_route(
64 onboarding: OnboardingState,
65 top_kind: Option<ModalKind>,
66 key: &KeyEvent,
67 ) -> OnboardingKeyRoute {
68 if onboarding == OnboardingState::None {
69 return OnboardingKeyRoute::Legacy;
70 }
71 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
72 return OnboardingKeyRoute::Quit;
73 }
74 // Checked before the picker claim: the offline exit must stay reachable
75 // from behind a modal the user cannot satisfy.
76 if onboarding == OnboardingState::Provider && is_explore_offline_shortcut(key) {
77 return OnboardingKeyRoute::ExploreOffline;
78 }
79 if onboarding == OnboardingState::Provider && top_kind == Some(ModalKind::ProviderPicker) {
80 return OnboardingKeyRoute::ProviderPicker;
81 }
82 OnboardingKeyRoute::Legacy
83 }
84
85 pub(crate) fn back_from_provider_onboarding(app: &mut App) {
86 app.onboarding_key_rejected = None;
87 if app.onboarding_missing_key_recovery {
88 // A returning user declined missing-key recovery: leave onboarding
89 // for the offline composer without mutating the saved route.
90 app.onboarding = OnboardingState::None;
91 app.status_message = None;
92 app.needs_redraw = true;
93 return;
94 }
95 // Esc walks back to the previous decision this run actually asked: the
96 // language screen when it appeared, otherwise the welcome screen.
97 app.onboarding = if app.onboarding_had_language_step {
98 OnboardingState::Language
99 } else {
100 OnboardingState::Welcome
101 };
102 app.status_message = None;
103 }
104
105 pub(crate) fn complete_provider_picker_onboarding(app: &mut App, provider: ProviderKind) {
106 // Ordinary `/provider` changes stay session-local until the operator
107 // answers the route-save prompt. Onboarding is different: choosing a
108 // provider is the explicit decision that establishes the startup route.
109 // Persist the exact live identity/model before advancing, otherwise a
110 // clean first run can finish on Ollama (or another non-DeepSeek route)
111 // while the next launch silently reconstructs the old DeepSeek default.
112 // The user-global `config.toml` owns this startup choice.
113 let provider_action_receipt = app.status_message.take();
114 let startup_default_receipt = match app.try_save_live_route_as_startup_default() {
115 Ok(receipt) => receipt,
116 Err(err) => {
117 // Persistence is part of completing first-run provider setup. Keep
118 // the provider step active on failure so the current session may
119 // use the selected route, but onboarding cannot claim that the
120 // next launch will restore it. The exact selected provider remains
121 // focused for an immediate retry.
122 app.onboarding_provider = provider;
123 app.onboarding_needs_api_key = true;
124 app.status_message = Some(match provider_action_receipt {
125 Some(receipt) if !receipt.trim().is_empty() => {
126 format!("{receipt} · Save failed: {err}")
127 }
128 _ => format!("Save failed: {err}"),
129 });
130 app.needs_redraw = true;
131 return;
132 }
133 };
134 app.onboarding_provider = provider;
135 app.onboarding_needs_api_key = false;
136 app.onboarding_key_rejected = None;
137 // The route now has its key, so a later local-Ollama probe must not treat
138 // this session as still recovering from a missing one.
139 app.onboarding_missing_key_recovery = false;
140 app.api_key_env_only = false;
141 app.offline_mode = false;
142 onboarding::advance_onboarding_after_provider(app);
143 // `advance_onboarding_after_provider` clears the previous switch status.
144 // Restore the persistence receipt last so an I/O failure remains visible
145 // instead of allowing onboarding to imply that the restart route landed.
146 app.status_message = Some(match provider_action_receipt {
147 Some(receipt) if !receipt.trim().is_empty() => {
148 format!("{receipt} · {startup_default_receipt}")
149 }
150 _ => startup_default_receipt,
151 });
152 }
153
154 pub(crate) fn complete_provider_picker_onboarding_if_switched(
155 app: &mut App,
156 provider: ProviderKind,
157 switched: bool,
158 ) {
159 if switched && app.onboarding == OnboardingState::Provider {
160 complete_provider_picker_onboarding(app, provider);
161 }
162 }
163
164 /// How one prepaid provider publishes remaining credit. Each variant is a
165 /// distinct wire contract — do not send DeepSeek `/user/balance` to a
166 /// provider that does not speak it.
167 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
168 enum BalanceApi {
169 DeepSeekUserBalance,
170 OpenRouterCredits,
171 SiliconFlowUserInfo,
172 }
173
174 fn balance_api_for(provider: ProviderKind) -> Option<BalanceApi> {
175 match provider {
176 ProviderKind::Deepseek => Some(BalanceApi::DeepSeekUserBalance),
177 ProviderKind::Openrouter => Some(BalanceApi::OpenRouterCredits),
178 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
179 Some(BalanceApi::SiliconFlowUserInfo)
180 }
181 _ => None,
182 }
183 }
184
185 /// Fetch remaining credit for the active prepaid provider.
186 ///
187 /// Returns `None` on any error (network, auth, parse) — callers treat that
188 /// as "balance unknown" and keep the previous value.
189 pub(crate) async fn fetch_provider_balance(
190 provider: ProviderKind,
191 api_key: &str,
192 base_url: &str,
193 ) -> Option<crate::pricing::BalanceInfo> {
194 let api_key = api_key.trim();
195 if api_key.is_empty() {
196 return None;
197 }
198 match balance_api_for(provider)? {
199 BalanceApi::DeepSeekUserBalance => fetch_deepseek_user_balance(api_key, base_url).await,
200 BalanceApi::OpenRouterCredits => fetch_openrouter_credits(api_key, base_url).await,
201 BalanceApi::SiliconFlowUserInfo => {
202 fetch_siliconflow_user_info(api_key, base_url, provider).await
203 }
204 }
205 }
206
207 async fn fetch_deepseek_user_balance(
208 api_key: &str,
209 base_url: &str,
210 ) -> Option<crate::pricing::BalanceInfo> {
211 let url = format!("{}/user/balance", base_url.trim_end_matches('/'));
212 let body: crate::pricing::BalanceResponse = balance_get_json(api_key, &url).await?;
213 body.balance_infos.into_iter().next()
214 }
215
216 #[derive(serde::Deserialize)]
217 struct OpenRouterCreditsResponse {
218 data: OpenRouterCreditsData,
219 }
220
221 #[derive(serde::Deserialize)]
222 struct OpenRouterCreditsData {
223 total_credits: f64,
224 total_usage: f64,
225 }
226
227 fn openrouter_remaining_credits(total_credits: f64, total_usage: f64) -> f64 {
228 (total_credits - total_usage).max(0.0)
229 }
230
231 async fn fetch_openrouter_credits(
232 api_key: &str,
233 base_url: &str,
234 ) -> Option<crate::pricing::BalanceInfo> {
235 let url = format!("{}/credits", base_url.trim_end_matches('/'));
236 let body: OpenRouterCreditsResponse = balance_get_json(api_key, &url).await?;
237 let remaining = openrouter_remaining_credits(body.data.total_credits, body.data.total_usage);
238 Some(crate::pricing::BalanceInfo {
239 currency: "USD".to_string(),
240 total_balance: format!("{remaining:.2}"),
241 topped_up_balance: format!("{:.2}", body.data.total_credits),
242 granted_balance: String::new(),
243 })
244 }
245
246 #[derive(serde::Deserialize)]
247 struct SiliconFlowUserInfo {
248 data: Option<SiliconFlowUserData>,
249 }
250
251 #[derive(serde::Deserialize)]
252 struct SiliconFlowUserData {
253 #[serde(default, alias = "totalBalance")]
254 total_balance: Option<String>,
255 #[serde(default, alias = "chargeBalance")]
256 charge_balance: Option<String>,
257 #[serde(default)]
258 balance: Option<String>,
259 }
260
261 async fn fetch_siliconflow_user_info(
262 api_key: &str,
263 base_url: &str,
264 provider: ProviderKind,
265 ) -> Option<crate::pricing::BalanceInfo> {
266 let url = format!("{}/user/info", base_url.trim_end_matches('/'));
267 let body: SiliconFlowUserInfo = balance_get_json(api_key, &url).await?;
268 let data = body.data?;
269 let total = data
270 .total_balance
271 .as_deref()
272 .or(data.balance.as_deref())
273 .map(str::trim)
274 .filter(|value| !value.is_empty())?;
275 let currency = if provider == ProviderKind::SiliconflowCN {
276 "CNY"
277 } else {
278 "USD"
279 };
280 Some(crate::pricing::BalanceInfo {
281 currency: currency.to_string(),
282 total_balance: total.to_string(),
283 topped_up_balance: data.charge_balance.unwrap_or_default(),
284 granted_balance: String::new(),
285 })
286 }
287
288 async fn balance_get_json<T: serde::de::DeserializeOwned>(api_key: &str, url: &str) -> Option<T> {
289 let client = &*BALANCE_CLIENT;
290 let response = client
291 .get(url)
292 .header("Authorization", format!("Bearer {api_key}"))
293 .send()
294 .await
295 .ok()?;
296 if !response.status().is_success() {
297 tracing::debug!(
298 "balance API returned {}: {}",
299 response.status().as_u16(),
300 response.text().await.unwrap_or_default()
301 );
302 return None;
303 }
304 response.json().await.ok()
305 }
306
307 pub(crate) fn should_fetch_provider_balance(app: &App) -> bool {
308 app.status_items.contains(&StatusItem::Balance)
309 && crate::config::provider_has_balance_api(app.api_provider)
310 }
311
312 /// The balance cell for this route. A reading belongs to the route that
313 /// fetched it: when the provider, endpoint or key changes, a fresh cell
314 /// replaces the old one, so a response still in flight for the previous route
315 /// lands in a cell nothing reads (U03-08), and the new route is not held behind
316 /// the previous route's fetch cooldown.
317 pub(crate) fn balance_cell_for_route(
318 app: &mut App,
319 provider: ProviderKind,
320 api_key: &str,
321 base_url: &str,
322 ) -> std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>> {
323 use std::hash::{Hash, Hasher};
324 let mut key_fingerprint = std::collections::hash_map::DefaultHasher::new();
325 api_key.hash(&mut key_fingerprint);
326 let route = format!(
327 "{}\u{1f}{base_url}\u{1f}{:016x}",
328 provider.as_str(),
329 key_fingerprint.finish()
330 );
331 if app.balance_route.as_deref() != Some(route.as_str()) {
332 app.balance_cell = std::sync::Arc::new(std::sync::Mutex::new(None));
333 app.balance_route = Some(route);
334 app.last_balance_fetch = None;
335 }
336 app.balance_cell.clone()
337 }
338
339 /// Kick a background remaining-credit fetch for the live route.
340 ///
341 /// `force` skips the status-item gate (used by `/balance`). Providers without
342 /// a known endpoint clear the parked chip so a previous route cannot linger.
343 pub(crate) fn schedule_balance_fetch(app: &mut App, api_key: &str, base_url: &str, force: bool) {
344 if !crate::config::provider_has_balance_api(app.api_provider) {
345 // A fresh cell, not a cleared one: a fetch still in flight for the
346 // previous route holds the old cell and cannot repaint the chip.
347 app.balance_cell = std::sync::Arc::new(std::sync::Mutex::new(None));
348 app.balance_route = None;
349 return;
350 }
351 let provider = app.api_provider;
352 let cell = balance_cell_for_route(app, provider, api_key, base_url);
353 if !force && !should_fetch_provider_balance(app) {
354 return;
355 }
356 if api_key.trim().is_empty() {
357 return;
358 }
359 let cooldown_ok = force
360 || app
361 .last_balance_fetch
362 .is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
363 if !cooldown_ok {
364 return;
365 }
366 app.last_balance_fetch = Some(Instant::now());
367 let api_key = api_key.to_string();
368 let base_url = base_url.to_string();
369 tokio::spawn(async move {
370 if let Some(info) = fetch_provider_balance(provider, &api_key, &base_url).await
371 && let Ok(mut guard) = cell.lock()
372 {
373 *guard = Some(info);
374 }
375 });
376 }
377
378 #[cfg(test)]
379 pub(crate) fn openrouter_credits_from_json(json: &str) -> Option<crate::pricing::BalanceInfo> {
380 let body: OpenRouterCreditsResponse = serde_json::from_str(json).ok()?;
381 let remaining = openrouter_remaining_credits(body.data.total_credits, body.data.total_usage);
382 Some(crate::pricing::BalanceInfo {
383 currency: "USD".to_string(),
384 total_balance: format!("{remaining:.2}"),
385 topped_up_balance: format!("{:.2}", body.data.total_credits),
386 granted_balance: String::new(),
387 })
388 }
389
390 /// Route text from either clipboard transport into the canonical provider
391 /// picker. Keeping this small seam pure lets tests exercise ordinary
392 /// Cmd/Ctrl+V without reading the developer's real clipboard.
393 pub(crate) fn paste_text_into_provider_picker(app: &mut App, text: &str) -> bool {
394 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
395 return false;
396 }
397 let _ = app.view_stack.handle_paste(text);
398 true
399 }
400
401 /// Read an ordinary Cmd/Ctrl+V clipboard shortcut for the provider picker.
402 /// Images are deliberately consumed but ignored: an open credential modal
403 /// must never leak unsupported clipboard content into the composer beneath it.
404 pub(crate) fn paste_provider_picker_from_clipboard(app: &mut App) -> bool {
405 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
406 return false;
407 }
408 if app.clipboard.requires_terminal_paste() {
409 app.status_message = Some(app.tr(MessageId::ClipboardSshPasteHint).into_owned());
410 return true;
411 }
412 if let Some(ClipboardContent::Text(text)) = app.clipboard.read(app.workspace.as_path()) {
413 let _ = paste_text_into_provider_picker(app, &text);
414 }
415 true
416 }
417
418 pub(crate) async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
419 use crate::client::CodewhaleClient;
420
421 let client = CodewhaleClient::new(config)?;
422 let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??;
423 let mut ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
424 ids.sort();
425 ids.dedup();
426 Ok(ids)
427 }
428
429 pub(crate) fn resolve_cache_replay_route(
430 app: &App,
431 config: &Config,
432 ) -> Result<crate::route_runtime::ResolvedRuntimeRoute> {
433 let target = app.cache_replay_target().ok_or_else(|| {
434 anyhow::anyhow!("Auto has no concrete route yet; send a turn before warming its cache")
435 })?;
436 let identity = config
437 .resolve_persisted_provider_identity(
438 Some(target.provider.as_str()),
439 target.provider_id.as_deref(),
440 )
441 .map_err(anyhow::Error::msg)?;
442 if identity.provider != target.provider || identity.key.as_str() != target.provider_identity {
443 anyhow::bail!(
444 "saved cache route identity `{}` now resolves as {}/{} instead of {}/{}; send a new turn before warming",
445 target.provider_identity,
446 identity.provider.as_str(),
447 identity.key,
448 target.provider.as_str(),
449 target.provider_identity
450 );
451 }
452 let route = resolve_runtime_route_for_identity(config, &identity, Some(&target.model))
453 .map_err(anyhow::Error::msg)?;
454 if let Some(previous_base_url) = target.base_url.as_deref() {
455 let previous_endpoint = crate::route_receipt::endpoint_identity(previous_base_url);
456 let current_endpoint =
457 crate::route_receipt::endpoint_identity(&route.candidate.endpoint().base_url);
458 if previous_endpoint != current_endpoint {
459 anyhow::bail!(
460 "the cache route endpoint changed since the last turn; send a new turn before warming"
461 );
462 }
463 }
464 Ok(route)
465 }
466
467 pub(crate) fn error_health_route(app: &App) -> Option<(ProviderIdentity, String)> {
468 let route = app.active_turn.as_ref()?.route.as_ref()?;
469 let receipt = route.receipt.as_ref()?;
470 Some((receipt.admitted_identity().clone(), route.model.clone()))
471 }
472
473 pub(crate) fn rollback_provider_after_auth_failure(
474 app: &mut App,
475 config: &mut Config,
476 ) -> Option<String> {
477 let pending = app.pending_provider_switch.take()?;
478 let PendingProviderSwitch {
479 previous_provider,
480 previous_model,
481 previous_model_ids_passthrough,
482 previous_route_limits,
483 previous_route_base_url,
484 previous_context_window_source,
485 previous_context_window_override,
486 previous_config,
487 previous_onboarding,
488 previous_onboarding_needs_api_key,
489 previous_api_key_env_only,
490 } = pending;
491
492 *config = previous_config;
493
494 app.refresh_notification_settings(config);
495 app.provider_identity = config
496 .active_provider_identity()
497 .ok()
498 .filter(|identity| identity.provider == previous_provider);
499 app.api_provider = previous_provider;
500 app.billing_presentation = app.provider_identity.as_ref().map_or(
501 crate::route_billing::BillingPresentation::Unknown,
502 |identity| crate::route_billing::for_route(config, identity),
503 );
504
505 app.set_model_selection(previous_model.clone());
506 app.provider_models.insert(
507 app.provider_identity_for_persistence().to_string(),
508 previous_model,
509 );
510 // The rolled-back switch leaves the session where it started: any pending
511 // route-save decision belongs to the failed provider and must not linger.
512 app.pending_route_save = None;
513 app.model_ids_passthrough = previous_model_ids_passthrough;
514 app.active_context_window_override = previous_context_window_override;
515 app.active_route_limits = previous_route_limits;
516 app.active_route_base_url = previous_route_base_url;
517 app.active_context_window_source = previous_context_window_source;
518 app.update_model_compaction_budget();
519 app.clear_model_scoped_telemetry();
520 app.offline_mode = false;
521 app.onboarding = previous_onboarding;
522 app.onboarding_needs_api_key = previous_onboarding_needs_api_key;
523 app.api_key_env_only = previous_api_key_env_only;
524
525 // The failed switch never wrote config or settings, so the rollback has
526 // nothing to undo on disk — and it must not leave a pending save decision
527 // behind (cleared above). Only the on-screen setup-state receipt is
528 // corrected so the record matches reality.
529 let mut persistence_errors = Vec::new();
530 if let Err(err) = crate::tui::setup::record_provider_model_setup_state_for_app(app, config) {
531 persistence_errors.push(format!("setup state was not saved: {err}"));
532 }
533 let persistence_error = if persistence_errors.is_empty() {
534 None
535 } else {
536 Some(format!(
537 "provider rollback not fully persisted: {}",
538 persistence_errors.join("; ")
539 ))
540 };
541
542 Some(match persistence_error {
543 Some(warning) => format!(
544 "Provider switch failed and has been rolled back to {}. {}",
545 previous_provider.as_str(),
546 warning
547 ),
548 None => format!(
549 "Provider switch failed and has been rolled back to {}.",
550 previous_provider.as_str()
551 ),
552 })
553 }
554
555 pub(crate) fn validated_app_runtime_route(
556 app: &App,
557 config: &Config,
558 ) -> Result<crate::route_runtime::ValidatedRuntimeRoute, String> {
559 let (identity, scoped) = app_scoped_runtime_config(app, config)?;
560 resolve_runtime_route_for_identity(&scoped, &identity, Some(&app.model))?.validate()
561 }
562
563 pub(crate) fn compaction_for_validated_route(
564 app: &App,
565 route: &crate::route_runtime::ValidatedRuntimeRoute,
566 ) -> crate::compaction::CompactionConfig {
567 let mut config = app.compaction_config_for_route(
568 route.identity.provider,
569 &route.model,
570 crate::route_budget::known_route_limits(route.candidate.limits()),
571 );
572 config.image_input = route.candidate.capabilities().image_input;
573 config
574 }
575
576 pub(crate) fn validated_profile_default_route(
577 config: &Config,
578 ) -> Result<crate::route_runtime::ValidatedRuntimeRoute> {
579 let identity = config
580 .active_provider_identity()
581 .map_err(anyhow::Error::msg)?;
582 let model = config.default_model();
583 resolve_runtime_route_for_identity(config, &identity, Some(&model))
584 .and_then(crate::route_runtime::ResolvedRuntimeRoute::validate)
585 .map_err(anyhow::Error::msg)
586 }
587
588 pub(crate) fn reasoning_effort_receipt_for_route(
589 tier: ReasoningEffort,
590 provider: ProviderKind,
591 endpoint_identity: &str,
592 model: &str,
593 ) -> EffectiveReasoningEffort {
594 crate::work_graph::constrained_effective_reasoning_for_route(
595 tier.into(),
596 provider,
597 endpoint_identity,
598 model,
599 )
600 .map(Into::into)
601 .unwrap_or(EffectiveReasoningEffort::Tier(tier))
602 }
603
604 pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) {
605 // #6150: non-blocking send on the input path. ChangeMode is safe to drop
606 // on a full channel — `try_send` still publishes the live authority
607 // snapshot, which the drain applies before the next queued op.
608 let _ = engine_handle.try_send(Op::ChangeMode {
609 mode: app.mode,
610 allow_shell: app.allow_shell,
611 trust_mode: app.trust_mode,
612 auto_approve: app_auto_approve_enabled(app),
613 approval_mode: app.approval_mode,
614 configured_sandbox_mode: app.configured_sandbox_mode.clone(),
615 });
616 }
617
618 /// Apply a `/provider` switch by resolving a complete route candidate before
619 /// mutating state, then respawning the engine so the API client picks up the
620 /// new base URL/key. When `model_override` is set, it replaces the active
621 /// model post-switch after provider-scoped normalization.
622 pub(crate) async fn switch_provider(
623 app: &mut App,
624 engine_handle: &mut EngineHandle,
625 config: &mut Config,
626 identity: crate::config::ProviderIdentity,
627 model_override: Option<String>,
628 ) -> bool {
629 if let Err(reason) = config.verify_provider_identity(&identity) {
630 app.push_status_toast(reason, StatusToastLevel::Error, Some(8_000));
631 return false;
632 }
633 let target = identity.provider;
634 let previous_provider = app.api_provider;
635 let previous_identity = app.provider_identity_for_persistence().to_string();
636 let requested_identity = identity.key.to_string();
637 let previous_model = app.model.clone();
638 let previous_model_ids_passthrough = app.model_ids_passthrough;
639 let previous_config = config.clone();
640 app.pending_provider_switch = Some(PendingProviderSwitch {
641 previous_provider,
642 previous_model: previous_model.clone(),
643 previous_model_ids_passthrough,
644 previous_route_limits: app.active_route_limits,
645 previous_route_base_url: app.active_route_base_url.clone(),
646 previous_context_window_source: app.active_context_window_source,
647 previous_context_window_override: app.active_context_window_override,
648 previous_config: previous_config.clone(),
649 previous_onboarding: app.onboarding,
650 previous_onboarding_needs_api_key: app.onboarding_needs_api_key,
651 previous_api_key_env_only: app.api_key_env_only,
652 });
653
654 // A session-local switch keeps the same ownership rule the persisted
655 // writers apply (`reconcile_root_model_aliases`): the root alias the
656 // outgoing route was using moves onto that route's own leaf, so coming
657 // back lands on it instead of the catalog default. Every failure path
658 // below restores `previous_config`.
659 if let Some((outgoing, value)) = config.root_model_alias_owned_by_outgoing(&identity) {
660 if let Err(reason) = config.set_provider_model_override(&outgoing, Some(value)) {
661 app.pending_provider_switch = None;
662 app.push_status_toast(reason.to_string(), StatusToastLevel::Error, Some(8_000));
663 return false;
664 }
665 config.default_text_model = None;
666 }
667
668 let resolved_route =
669 match resolve_runtime_route_for_identity(config, &identity, model_override.as_deref()) {
670 Ok(route) => route,
671 Err(reason) => {
672 app.pending_provider_switch = None;
673 // #3830: if the switch failed only because the target provider has
674 // no key or local runtime, hand off to /provider already focused
675 // on that provider's key prompt instead of dead-ending with an
676 // error the user has to translate into an action.
677 if !crate::config::has_api_key_for(config, &identity)
678 && app.view_stack.top_kind() != Some(ModalKind::ProviderPicker)
679 {
680 let runtime_status = query_provider_runtime_status(engine_handle).await;
681 if let Some(picker) =
682 crate::tui::provider_picker::ProviderPickerView::new_for_missing_auth(
683 previous_provider,
684 &identity,
685 config,
686 runtime_status,
687 )
688 .map(|picker| {
689 picker
690 .with_locale(app.ui_locale)
691 .with_provider_health(&app.provider_health)
692 })
693 {
694 *config = previous_config;
695 app.refresh_notification_settings(config);
696 app.view_stack.push(picker);
697 app.status_message = Some(format!(
698 "{} needs a key or local runtime — enter one to switch.",
699 identity
700 .compatibility()
701 .map(|row| row.label)
702 .unwrap_or(identity.key.as_str())
703 ));
704 app.needs_redraw = true;
705 return false;
706 }
707 }
708 *config = previous_config;
709 app.refresh_notification_settings(config);
710 app.add_message(HistoryCell::System {
711 content: format!(
712 "Cannot switch to {}: {reason}\nProvider unchanged ({}).",
713 requested_identity, previous_identity
714 ),
715 });
716 app.status_message = Some(format!(
717 "Route rejected before provider switch: {}.",
718 target.as_str()
719 ));
720 return false;
721 }
722 };
723 let validated_route = match resolved_route.validate() {
724 Ok(route) => route,
725 Err(err) => {
726 app.pending_provider_switch = None;
727 *config = previous_config;
728 app.refresh_notification_settings(config);
729 app.add_message(HistoryCell::System {
730 content: format!(
731 "Failed to switch provider to {}: {err}\nProvider unchanged ({}).",
732 requested_identity, previous_identity
733 ),
734 });
735 return false;
736 }
737 };
738 let target_identity_record = validated_route.identity.clone();
739 let target_identity = target_identity_record.key.to_string();
740 let resolved_endpoint = validated_route.candidate.endpoint().base_url.clone();
741 let route_limits = validated_route.candidate.limits();
742 let context_window_source = validated_route.context_window.source;
743 let new_model = validated_route.model.clone();
744 *config = *validated_route.config;
745 app.refresh_notification_settings(config);
746
747 let new_base_url = resolved_endpoint;
748 let new_endpoint = display_base_url_host(&new_base_url);
749 let cache_scope_changed = previous_provider != target
750 || previous_identity != target_identity
751 || previous_model != new_model;
752 app.set_provider_identity_record(target_identity_record.clone());
753 // Launch computed "needs a key" for the launch provider. A switch to a
754 // route that has its credential answers that, even when the user left the
755 // picker with Esc first; otherwise the stale flag keeps the info line on
756 // "model not connected" and keeps local-Ollama adoption armed against the
757 // provider the user just chose. An auth-failure rollback restores it.
758 app.onboarding_needs_api_key = !crate::config::has_api_key(config);
759 if !app.onboarding_needs_api_key {
760 app.onboarding_missing_key_recovery = false;
761 }
762 app.billing_presentation = crate::route_billing::for_route(config, &target_identity_record);
763 app.max_subagents = config
764 .max_subagents_for_provider(&target_identity_record)
765 .clamp(1, crate::config::MAX_SUBAGENTS);
766 app.provider_chain = (target_identity_record.key.as_str() == target.as_str()
767 && target != ProviderKind::Antigravity)
768 .then(|| codewhale_config::ProviderChain::new(target, &config.fallback_providers))
769 .filter(|chain| chain.providers().len() > 1);
770 app.last_fallback_reason = None;
771 app.model_ids_passthrough = config.model_ids_pass_through();
772 app.set_model_selection(new_model.clone());
773 app.apply_provider_switch_reasoning_effort(target, &new_base_url, model_override.as_deref());
774 app.set_active_context_window_override(config, &target_identity_record);
775 app.set_active_route_resolution(new_base_url.clone(), route_limits, context_window_source);
776 if model_override.is_some() {
777 app.provider_models
778 .insert(target_identity.clone(), new_model.clone());
779 }
780 app.note_route_used(&target_identity, &new_model);
781 app.update_model_compaction_budget();
782 if cache_scope_changed {
783 app.clear_model_scoped_telemetry();
784 } else {
785 app.session.last_prompt_tokens = None;
786 app.session.last_completion_tokens = None;
787 }
788
789 let _ = engine_handle.send(Op::Shutdown).await;
790 let engine_config = build_engine_config(app, config);
791 *engine_handle = spawn_tui_engine(engine_config, config);
792 // A successful in-session switch must refresh the same key-scoped live
793 // catalog as startup. TelecomJS is currently the only provider using this
794 // seam; failures preserve the existing/static rows.
795 crate::client::CodewhaleClient::spawn_active_provider_catalog_refresh(config);
796
797 if !app.api_messages.is_empty() {
798 let _ = engine_handle
799 .send(Op::SyncSession {
800 session_id: app.current_session_id.clone(),
801 messages: app.api_messages.as_ref().clone(),
802 system_prompt: app.system_prompt.clone(),
803 system_prompt_override: false,
804 model: app.model.clone(),
805 workspace: app.workspace.clone(),
806 mode: app.mode,
807 })
808 .await;
809 }
810 let _ = engine_handle
811 .send(Op::SetCompaction {
812 config: app.compaction_config(),
813 })
814 .await;
815
816 // Route changes are temporary by default: nothing is written here. The
817 // route-save prompt offers the explicit persistence choices, so a
818 // workspace's config file can never be silently rewritten by a switch
819 // made in another folder.
820 app.note_session_route_change(&target_identity, &new_model);
821 let persist_warning: Option<String> = None;
822
823 // Re-selecting the same provider (the usual first-run key entry) is a
824 // connection, not a switch: "deepseek → deepseek" read as a glitch (#6566).
825 let mut switch_summary = if previous_identity == target_identity {
826 format!("Connected: {target_identity}")
827 } else {
828 format!("Provider switched: {previous_identity} → {target_identity}")
829 };
830 switch_summary.push(char::from(10));
831 if previous_model == new_model {
832 switch_summary.push_str(&format!("Model: {new_model}"));
833 } else {
834 switch_summary.push_str(&format!("Model: {previous_model} → {new_model}"));
835 }
836 switch_summary.push(char::from(10));
837 switch_summary.push_str(&format!("Endpoint: {new_endpoint}"));
838 if let Some(ref warning) = persist_warning {
839 switch_summary.push(char::from(10));
840 switch_summary.push_str(warning);
841 }
842 app.add_message(HistoryCell::System {
843 content: switch_summary,
844 });
845
846 let mut status_message = format!("Provider: {target_identity} via {new_endpoint}");
847 let persisted = persist_warning.is_none();
848 if persist_warning.is_some() {
849 status_message.push_str(" (not fully persisted)");
850 }
851 app.status_message = Some(status_message);
852 // #3927: activating a route is the single event that retires the
853 // explore-offline label. Nothing time-based or screen-based clears it.
854 onboarding::clear_offline_explore_on_route_activation(app);
855 if persisted {
856 record_provider_model_setup_progress(app, config);
857 }
858 true
859 }
860
861 pub(crate) fn display_base_url_host(base_url: &str) -> String {
862 let without_scheme = base_url
863 .split_once("://")
864 .map_or(base_url, |(_, rest)| rest);
865 without_scheme
866 .split('/')
867 .next()
868 .filter(|host| !host.is_empty())
869 .unwrap_or(base_url)
870 .to_string()
871 }
872
873 pub(crate) fn sync_config_provider_from_app(config: &mut Config, app: &App) {
874 config.provider = Some(app.provider_identity_for_persistence().to_string());
875 }
876
877 pub(crate) fn provider_picker_model_override(
878 app: &App,
879 config: &Config,
880 identity: &crate::config::ProviderIdentity,
881 ) -> Option<String> {
882 config.verify_provider_identity(identity).ok()?;
883 (app.admitted_provider_identity().ok() == Some(identity)).then(|| app.model.clone())
884 }
885
886 pub(crate) async fn query_provider_runtime_status(
887 engine_handle: &EngineHandle,
888 ) -> Option<ProviderRuntimeStatus> {
889 tokio::time::timeout(
890 Duration::from_millis(100),
891 engine_handle.get_provider_runtime_status(),
892 )
893 .await
894 .ok()
895 .and_then(|result| result.ok())
896 }
897
898 pub(crate) fn mcp_reload_summary(snapshot: &crate::mcp::McpManagerSnapshot) -> String {
899 let connected = snapshot
900 .servers
901 .iter()
902 .filter(|server| server.connected)
903 .count();
904 let failed = snapshot
905 .servers
906 .iter()
907 .filter(|server| server.enabled && server.error.is_some())
908 .count();
909 let disabled = snapshot
910 .servers
911 .iter()
912 .filter(|server| !server.enabled)
913 .count();
914 format!(
915 "MCP tool pool reloaded in process: {connected} connected, {failed} failed, {disabled} disabled. The next model turn uses this catalog."
916 )
917 }
918
919 pub(crate) fn mcp_server_diagnosis(app: &App, name: &str) -> String {
920 let Some(server) = app
921 .mcp_snapshot
922 .as_ref()
923 .and_then(|snapshot| snapshot.servers.iter().find(|server| server.name == name))
924 else {
925 return app
926 .tr(MessageId::McpDiagnosisUnobserved)
927 .replace("{server}", name)
928 .replace("{command}", "/mcp");
929 };
930 let state = if !server.enabled {
931 MessageId::McpStateDisabled
932 } else if server.connected {
933 MessageId::ExtensionsStateConnected
934 } else if server.auth_required {
935 MessageId::McpStateAuthorizationRequired
936 } else if server.error.is_some() {
937 MessageId::McpStateFailed
938 } else {
939 MessageId::McpStateDisconnected
940 };
941 let mut receipt = app
942 .tr(MessageId::McpDiagnosisSummary)
943 .replace("{server}", name)
944 .replace("{state}", &app.tr(state))
945 .replace("{transport}", &server.transport)
946 .replace("{tools}", &server.tools.len().to_string())
947 .replace("{resources}", &server.resources.len().to_string())
948 .replace("{prompts}", &server.prompts.len().to_string());
949 if let Some(error) = &server.error {
950 receipt.push(' ');
951 receipt.push_str(&app.tr(MessageId::McpDiagnosisLastError).replace(
952 "{error}",
953 &codewhale_config::persistence::redact_secrets(error),
954 ));
955 }
956 if crate::mcp::mcp_name_is_command_safe(name) {
957 let command = if !server.enabled {
958 format!("/mcp enable {name}")
959 } else if server.auth_required {
960 format!("/mcp login {name}")
961 } else {
962 format!("/mcp retry {name}")
963 };
964 receipt.push(' ');
965 receipt.push_str(
966 &app.tr(MessageId::McpDiagnosisNext)
967 .replace("{command}", &command),
968 );
969 } else {
970 receipt.push(' ');
971 receipt.push_str(
972 &app.tr(MessageId::McpDiagnosisNext)
973 .replace("{command}", "/mcp reload"),
974 );
975 }
976 receipt
977 }
978
979 pub(crate) fn mcp_ui_action_refreshes_discovery(action: &crate::tui::app::McpUiAction) -> bool {
980 matches!(
981 action,
982 crate::tui::app::McpUiAction::Validate
983 | crate::tui::app::McpUiAction::Logout { .. }
984 | crate::tui::app::McpUiAction::ImportList
985 | crate::tui::app::McpUiAction::ImportApprove { .. }
986 )
987 }
988
989 pub(crate) fn mcp_external_import_status_text(
990 workspace: &std::path::Path,
991 mcp_path: &std::path::Path,
992 plugins: &crate::plugins::PluginRegistry,
993 ) -> String {
994 use crate::mcp::external_import::{ImportContext, preview_imports};
995 let result = ImportContext::new(workspace, mcp_path, plugins)
996 .and_then(|context| preview_imports(&context));
997 match result {
998 Err(error) => format!("Cannot review MCP imports: {error}"),
999 Ok(preview) => {
1000 let mut lines = vec!["Review external connectors. Imports stay OFF; enable and test separately. Credential values and command arguments are hidden.".to_string()];
1001 for candidate in preview.candidates {
1002 lines.push(format!(
1003 "\n{} — {} · {} arguments · {}\nSource: {}\nContent: {}",
1004 candidate.name,
1005 candidate.destination,
1006 candidate.argument_count,
1007 if candidate.hard_blocked {
1008 "BLOCKED"
1009 } else if candidate.conflict {
1010 "NAME IN USE"
1011 } else {
1012 "Ready for review"
1013 },
1014 candidate.source_path.display(),
1015 candidate.content_hash
1016 ));
1017 if !candidate.hard_blocked && !candidate.conflict {
1018 lines.push(format!(
1019 "Approve: /mcp import approve {}",
1020 candidate.review_token
1021 ));
1022 }
1023 lines.push(format!(
1024 "Decline: /mcp import decline {}",
1025 candidate.review_token
1026 ));
1027 }
1028 for problem in preview.problems {
1029 lines.push(format!(
1030 "{}: {}",
1031 problem.source_kind.as_str(),
1032 problem.message
1033 ));
1034 }
1035 lines.join("\n")
1036 }
1037 }
1038 }
1039
1040 pub(crate) fn mcp_import_apply(
1041 workspace: &std::path::Path,
1042 mcp_path: &std::path::Path,
1043 plugins: &crate::plugins::PluginRegistry,
1044 token: &str,
1045 approve: bool,
1046 ) -> anyhow::Result<String> {
1047 use crate::mcp::external_import::{
1048 ImportContext, ImportDecision, apply_reviewed_import, parse_review_token,
1049 };
1050 let (id, hash, revision) = parse_review_token(token)?;
1051 let context = ImportContext::new(workspace, mcp_path, plugins)?;
1052 let receipt = apply_reviewed_import(
1053 &context,
1054 id,
1055 hash,
1056 revision,
1057 if approve {
1058 ImportDecision::Approve
1059 } else {
1060 ImportDecision::Decline
1061 },
1062 )?;
1063 let mut message = if receipt.imported {
1064 format!(
1065 "Imported '{}' with the connector OFF. Enable it explicitly, then test its connection.",
1066 receipt.name
1067 )
1068 } else {
1069 format!(
1070 "Declined '{}'; connector configuration was unchanged.",
1071 receipt.name
1072 )
1073 };
1074 if let Some(warning) = receipt.warning {
1075 message.push(' ');
1076 message.push_str(&warning);
1077 }
1078 Ok(message)
1079 }
1080
1081 pub(crate) fn clear_active_provider_api_key_from_memory(
1082 app: &App,
1083 config: &mut Config,
1084 ) -> Result<(), String> {
1085 let identity = app.admitted_provider_identity()?;
1086 config.verify_provider_identity(identity)?;
1087 config
1088 .set_provider_api_key_override(identity, None)
1089 .map_err(|error| error.to_string())?;
1090 // Region presentation shares the intrinsic secret owner through D6.
1091 if identity.key.as_str() == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id {
1092 let primary = config.builtin_provider_identity(ProviderKind::Deepseek)?;
1093 config
1094 .set_provider_api_key_override(&primary, None)
1095 .map_err(|error| error.to_string())?;
1096 }
1097 if identity.provider == ProviderKind::Xai {
1098 let entry = config
1099 .provider_config_for_mut(identity)
1100 .map_err(|error| error.to_string())?;
1101 entry.auth_mode = None;
1102 entry.oauth_credential_generation = None;
1103 entry.external_credentials = None;
1104 }
1105 Ok(())
1106 }
1107
1108 pub(crate) fn record_provider_model_setup_progress(app: &mut App, config: &Config) {
1109 if let Err(err) = crate::tui::setup::record_provider_model_setup_state_for_app(app, config) {
1110 let note = format!("Setup provider/model state was not saved: {err}");
1111 if let Some(status) = app.status_message.as_mut() {
1112 status.push_str(" · ");
1113 status.push_str(&note);
1114 } else {
1115 app.status_message = Some(note.clone());
1116 }
1117 app.add_message(HistoryCell::System { content: note });
1118 }
1119 }
1120
1121 #[cfg(test)]
1122 pub(crate) fn picker_provider_identity(
1123 config: &Config,
1124 provider: ProviderKind,
1125 provider_id: Option<&str>,
1126 ) -> Result<crate::config::ProviderIdentity, String> {
1127 let identity = match provider_id {
1128 Some(provider_id) => config
1129 .resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider_id))?,
1130 None if provider == ProviderKind::Custom => config.active_provider_identity()?,
1131 None => config.resolve_persisted_provider_identity(
1132 Some(provider.as_str()),
1133 Some(provider.as_str()),
1134 )?,
1135 };
1136 if identity.provider != provider {
1137 return Err(format!(
1138 "provider picker identity '{}' resolved as {}, not {}",
1139 identity.key,
1140 identity.provider.as_str(),
1141 provider.as_str()
1142 ));
1143 }
1144 Ok(identity)
1145 }
1146
1147 pub(crate) fn provider_verification_error_category(
1148 reason: &str,
1149 ) -> crate::error_taxonomy::ErrorCategory {
1150 let lower = reason.to_ascii_lowercase();
1151 if lower.contains("http 401") || lower.contains("status 401") {
1152 crate::error_taxonomy::ErrorCategory::Authentication
1153 } else if lower.contains("http 403") || lower.contains("status 403") {
1154 crate::error_taxonomy::ErrorCategory::Authorization
1155 } else if ["500", "502", "503", "504"]
1156 .iter()
1157 .any(|status| lower.contains(&format!("http {status}")))
1158 {
1159 crate::error_taxonomy::ErrorCategory::Network
1160 } else {
1161 crate::error_taxonomy::classify_error_message(reason)
1162 }
1163 }
1164
1164 lines RUST