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