返回 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 onboarding::mark_trusted(&app.workspace).map_err(|err| err.to_string())?;
14 app.trust_mode = true;
15 // `rebind`, not `new`: trusting the directory can add project hooks, but
16 // it does not start a new session. Hooks that already fired this session
17 // reported a `DEEPSEEK_SESSION_ID`, and it has to keep meaning the same
18 // session afterwards.
19 app.hooks = app.hooks.rebind(
20 crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &app.workspace),
21 app.workspace.clone(),
22 );
23 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
24 app.status_message = None;
25 advance_after_trust_directory_choice(app);
26 Ok(())
27 }
28
29 /// Continue past the trust step without recording workspace trust.
30 ///
31 /// Tools and hooks stay restricted for this session; the next launch will
32 /// re-prompt until the user trusts (or uses an explicit trust command).
33 pub(crate) fn continue_without_trusting_directory(app: &mut App) {
34 app.trust_mode = false;
35 app.status_message = Some(app.tr(MessageId::OnboardTrustUntrustedNotice).to_string());
36 advance_after_trust_directory_choice(app);
37 }
38
39 pub(crate) fn advance_after_trust_directory_choice(app: &mut App) {
40 if app.onboarding_workspace_trust_gate {
41 app.onboarding_workspace_trust_gate = false;
42 app.onboarding = OnboardingState::None;
43 } else if app.onboarding_missing_key_recovery {
44 app.onboarding = OnboardingState::Tips;
45 } else {
46 app.onboarding = OnboardingState::MentalModels;
47 }
48 }
49
50 /// Decide the onboarding route for one key press.
51 ///
52 /// Two invariants this encodes, both regressions reported in #4763:
53 /// Ctrl+C quits from *any* onboarding state — a modal on the stack must not
54 /// swallow it — and Escape is never intercepted on the picker's behalf, so
55 /// the picker can back out one stage at a time instead of the shell popping
56 /// the whole modal from a key/OAuth sub-stage.
57 pub(crate) fn onboarding_key_route(
58 onboarding: OnboardingState,
59 top_kind: Option<ModalKind>,
60 key: &KeyEvent,
61 ) -> OnboardingKeyRoute {
62 if onboarding == OnboardingState::None {
63 return OnboardingKeyRoute::Legacy;
64 }
65 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
66 return OnboardingKeyRoute::Quit;
67 }
68 // Checked before the picker claim: the offline exit must stay reachable
69 // from behind a modal the user cannot satisfy.
70 if onboarding == OnboardingState::Provider && is_explore_offline_shortcut(key) {
71 return OnboardingKeyRoute::ExploreOffline;
72 }
73 if onboarding == OnboardingState::Provider && top_kind == Some(ModalKind::ProviderPicker) {
74 return OnboardingKeyRoute::ProviderPicker;
75 }
76 if onboarding == OnboardingState::Appearance && top_kind == Some(ModalKind::ThemePicker) {
77 return OnboardingKeyRoute::ThemePicker;
78 }
79 OnboardingKeyRoute::Legacy
80 }
81
82 pub(crate) fn back_from_provider_onboarding(app: &mut App) {
83 if app.onboarding_missing_key_recovery {
84 // A returning user declined missing-key recovery: leave onboarding
85 // for the offline composer without mutating the saved route.
86 app.onboarding = OnboardingState::None;
87 app.status_message = None;
88 app.needs_redraw = true;
89 return;
90 }
91 app.onboarding = OnboardingState::Language;
92 app.status_message = None;
93 }
94
95 pub(crate) fn complete_provider_picker_onboarding(app: &mut App, provider: ApiProvider) {
96 app.onboarding_provider = provider;
97 app.onboarding_needs_api_key = false;
98 app.api_key_env_only = false;
99 app.offline_mode = false;
100 onboarding::advance_onboarding_after_provider(app);
101 }
102
103 pub(crate) fn complete_provider_picker_onboarding_if_switched(
104 app: &mut App,
105 provider: ApiProvider,
106 switched: bool,
107 ) {
108 if switched && app.onboarding == OnboardingState::Provider {
109 complete_provider_picker_onboarding(app, provider);
110 }
111 }
112
113 /// Fetch the DeepSeek account balance from the balance API.
114 ///
115 /// Returns `None` on any error (network, auth, parse) — callers should treat
116 /// a `None` return as "balance unknown" and keep the previous value.
117 pub(crate) async fn fetch_deepseek_balance(
118 api_key: &str,
119 base_url: &str,
120 ) -> Option<crate::pricing::BalanceInfo> {
121 let url = format!("{}/user/balance", base_url.trim_end_matches('/'));
122 let client = &*BALANCE_CLIENT;
123 let response = client
124 .get(url)
125 .header("Authorization", format!("Bearer {api_key}"))
126 .send()
127 .await
128 .ok()?;
129 if !response.status().is_success() {
130 tracing::debug!(
131 "balance API returned {}: {}",
132 response.status().as_u16(),
133 response.text().await.unwrap_or_default()
134 );
135 return None;
136 }
137 let body: crate::pricing::BalanceResponse = response.json().await.ok()?;
138 // Return the first balance entry (typically the user's primary currency).
139 body.balance_infos.into_iter().next()
140 }
141
142 pub(crate) fn should_fetch_deepseek_balance(app: &App) -> bool {
143 app.status_items.contains(&StatusItem::Balance)
144 && matches!(
145 app.api_provider,
146 ApiProvider::Deepseek | ApiProvider::DeepseekCN
147 )
148 }
149
150 /// Route text from either clipboard transport into the canonical provider
151 /// picker. Keeping this small seam pure lets tests exercise ordinary
152 /// Cmd/Ctrl+V without reading the developer's real clipboard.
153 pub(crate) fn paste_text_into_provider_picker(app: &mut App, text: &str) -> bool {
154 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
155 return false;
156 }
157 let _ = app.view_stack.handle_paste(text);
158 true
159 }
160
161 /// Read an ordinary Cmd/Ctrl+V clipboard shortcut for the provider picker.
162 /// Images are deliberately consumed but ignored: an open credential modal
163 /// must never leak unsupported clipboard content into the composer beneath it.
164 pub(crate) fn paste_provider_picker_from_clipboard(app: &mut App) -> bool {
165 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
166 return false;
167 }
168 if app.clipboard.requires_terminal_paste() {
169 app.status_message = Some(app.tr(MessageId::ClipboardSshPasteHint).into_owned());
170 return true;
171 }
172 if let Some(ClipboardContent::Text(text)) = app.clipboard.read(app.workspace.as_path()) {
173 let _ = paste_text_into_provider_picker(app, &text);
174 }
175 true
176 }
177
178 pub(crate) async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
179 use crate::client::DeepSeekClient;
180
181 let client = DeepSeekClient::new(config)?;
182 let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??;
183 let mut ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
184 ids.sort();
185 ids.dedup();
186 Ok(ids)
187 }
188
189 pub(crate) fn resolve_cache_replay_route(
190 app: &App,
191 config: &Config,
192 ) -> Result<crate::route_runtime::ResolvedRuntimeRoute> {
193 let target = app.cache_replay_target().ok_or_else(|| {
194 anyhow::anyhow!("Auto has no concrete route yet; send a turn before warming its cache")
195 })?;
196 let identity = config
197 .resolve_persisted_provider_identity(
198 Some(target.provider.as_str()),
199 target.provider_id.as_deref(),
200 )
201 .map_err(anyhow::Error::msg)?;
202 if identity.provider != target.provider || identity.key != target.provider_identity {
203 anyhow::bail!(
204 "saved cache route identity `{}` now resolves as {}/{} instead of {}/{}; send a new turn before warming",
205 target.provider_identity,
206 identity.provider.as_str(),
207 identity.key,
208 target.provider.as_str(),
209 target.provider_identity
210 );
211 }
212 let route = resolve_runtime_route_for_identity(config, &identity, Some(&target.model))
213 .map_err(anyhow::Error::msg)?;
214 if let Some(previous_base_url) = target.base_url.as_deref() {
215 let previous_endpoint = crate::route_receipt::endpoint_identity(previous_base_url);
216 let current_endpoint =
217 crate::route_receipt::endpoint_identity(&route.candidate.endpoint().base_url);
218 if previous_endpoint != current_endpoint {
219 anyhow::bail!(
220 "the cache route endpoint changed since the last turn; send a new turn before warming"
221 );
222 }
223 }
224 Ok(route)
225 }
226
227 pub(crate) fn error_health_route(
228 app: &App,
229 fallback_provider: ApiProvider,
230 ) -> (ApiProvider, String) {
231 app.active_turn
232 .as_ref()
233 .and_then(|turn| turn.route.as_ref())
234 .map(|route| (route.provider, route.model.clone()))
235 .or_else(|| {
236 app.pending_turn_route
237 .as_ref()
238 .map(|(provider, model, _)| (*provider, model.clone()))
239 })
240 .unwrap_or_else(|| (fallback_provider, app.model.clone()))
241 }
242
243 pub(crate) fn rollback_provider_after_auth_failure(
244 app: &mut App,
245 config: &mut Config,
246 ) -> Option<String> {
247 let pending = app.pending_provider_switch.take()?;
248 let PendingProviderSwitch {
249 previous_provider,
250 previous_model,
251 previous_model_ids_passthrough,
252 previous_route_limits,
253 previous_route_base_url,
254 previous_context_window_source,
255 previous_context_window_override,
256 previous_config,
257 previous_onboarding,
258 previous_onboarding_needs_api_key,
259 previous_api_key_env_only,
260 } = pending;
261
262 *config = previous_config;
263 if let Ok(identity) = config.active_provider_identity(previous_provider) {
264 app.set_provider_identity_record(identity);
265 } else {
266 app.set_provider_identity(
267 previous_provider,
268 config.provider_identity_for(previous_provider),
269 );
270 }
271 app.billing_presentation = crate::route_billing::for_route(config, previous_provider);
272 app.set_model_selection(previous_model.clone());
273 app.provider_models.insert(
274 app.provider_identity_for_persistence().to_string(),
275 previous_model,
276 );
277 // The rolled-back switch leaves the session where it started: any pending
278 // route-save decision belongs to the failed provider and must not linger.
279 app.pending_route_save = None;
280 app.model_ids_passthrough = previous_model_ids_passthrough;
281 app.active_context_window_override = previous_context_window_override;
282 app.active_route_limits = previous_route_limits;
283 app.active_route_base_url = previous_route_base_url;
284 app.active_context_window_source = previous_context_window_source;
285 app.update_model_compaction_budget();
286 app.clear_model_scoped_telemetry();
287 app.offline_mode = false;
288 app.onboarding = previous_onboarding;
289 app.onboarding_needs_api_key = previous_onboarding_needs_api_key;
290 app.api_key_env_only = previous_api_key_env_only;
291
292 // The failed switch never wrote config or settings, so the rollback has
293 // nothing to undo on disk — and it must not leave a pending save decision
294 // behind (cleared above). Only the on-screen setup-state receipt is
295 // corrected so the record matches reality.
296 let mut persistence_errors = Vec::new();
297 if let Err(err) = crate::tui::setup::record_provider_model_setup_state_for_app(app, config) {
298 persistence_errors.push(format!("setup state was not saved: {err}"));
299 }
300 let persistence_error = if persistence_errors.is_empty() {
301 None
302 } else {
303 Some(format!(
304 "provider rollback not fully persisted: {}",
305 persistence_errors.join("; ")
306 ))
307 };
308
309 Some(match persistence_error {
310 Some(warning) => format!(
311 "Provider switch failed and has been rolled back to {}. {}",
312 previous_provider.as_str(),
313 warning
314 ),
315 None => format!(
316 "Provider switch failed and has been rolled back to {}.",
317 previous_provider.as_str()
318 ),
319 })
320 }
321
322 pub(crate) fn validated_app_runtime_route(
323 app: &App,
324 config: &Config,
325 ) -> Result<crate::route_runtime::ValidatedRuntimeRoute, String> {
326 let (identity, scoped) = app_scoped_runtime_config(app, config);
327 resolve_runtime_route_for_identity(&scoped, &identity, Some(&app.model))?.validate()
328 }
329
330 pub(crate) fn compaction_for_validated_route(
331 app: &App,
332 route: &crate::route_runtime::ValidatedRuntimeRoute,
333 ) -> crate::compaction::CompactionConfig {
334 app.compaction_config_for_route(
335 route.identity.provider,
336 &route.model,
337 crate::route_budget::known_route_limits(route.candidate.limits()),
338 )
339 }
340
341 pub(crate) fn validated_profile_default_route(
342 config: &Config,
343 ) -> Result<crate::route_runtime::ValidatedRuntimeRoute> {
344 let provider = config.api_provider();
345 let model = config.default_model();
346 resolve_runtime_route(config, provider, Some(&model))
347 .and_then(crate::route_runtime::ResolvedRuntimeRoute::validate)
348 .map_err(anyhow::Error::msg)
349 }
350
351 pub(crate) fn reasoning_effort_receipt_for_route(
352 tier: ReasoningEffort,
353 provider: ApiProvider,
354 endpoint_identity: &str,
355 model: &str,
356 ) -> EffectiveReasoningEffort {
357 crate::work_graph::constrained_effective_reasoning_for_route(
358 tier.into(),
359 provider,
360 endpoint_identity,
361 model,
362 )
363 .map(Into::into)
364 .unwrap_or(EffectiveReasoningEffort::Tier(tier))
365 }
366
367 pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) {
368 let _ = engine_handle
369 .send(Op::ChangeMode {
370 mode: app.mode,
371 allow_shell: app.allow_shell,
372 trust_mode: app.trust_mode,
373 auto_approve: app_auto_approve_enabled(app),
374 approval_mode: app.approval_mode,
375 configured_sandbox_mode: app.configured_sandbox_mode.clone(),
376 })
377 .await;
378 }
379
380 /// Apply a `/provider` switch by resolving a complete route candidate before
381 /// mutating state, then respawning the engine so the API client picks up the
382 /// new base URL/key. When `model_override` is set, it replaces the active
383 /// model post-switch after provider-scoped normalization.
384 pub(crate) async fn switch_provider(
385 app: &mut App,
386 engine_handle: &mut EngineHandle,
387 config: &mut Config,
388 target: ApiProvider,
389 model_override: Option<String>,
390 ) -> bool {
391 let previous_provider = app.api_provider;
392 let previous_identity = app.provider_identity_for_persistence().to_string();
393 let requested_identity = config.provider_identity_for(target);
394 let previous_model = app.model.clone();
395 let previous_model_ids_passthrough = app.model_ids_passthrough;
396 let mut previous_config = config.clone();
397 previous_config.provider = Some(previous_identity.clone());
398 app.pending_provider_switch = Some(PendingProviderSwitch {
399 previous_provider,
400 previous_model: previous_model.clone(),
401 previous_model_ids_passthrough,
402 previous_route_limits: app.active_route_limits,
403 previous_route_base_url: app.active_route_base_url.clone(),
404 previous_context_window_source: app.active_context_window_source,
405 previous_context_window_override: app.active_context_window_override,
406 previous_config: previous_config.clone(),
407 previous_onboarding: app.onboarding,
408 previous_onboarding_needs_api_key: app.onboarding_needs_api_key,
409 previous_api_key_env_only: app.api_key_env_only,
410 });
411
412 let resolved_route = match resolve_runtime_route(config, target, model_override.as_deref()) {
413 Ok(route) => route,
414 Err(reason) => {
415 app.pending_provider_switch = None;
416 // #3830: if the switch failed only because the target provider has
417 // no key or local runtime, hand off to /provider already focused
418 // on that provider's key prompt instead of dead-ending with an
419 // error the user has to translate into an action.
420 if !crate::config::has_api_key_for(config, target)
421 && app.view_stack.top_kind() != Some(ModalKind::ProviderPicker)
422 {
423 let runtime_status = query_provider_runtime_status(engine_handle).await;
424 if let Some(picker) =
425 crate::tui::provider_picker::ProviderPickerView::new_for_missing_auth(
426 previous_provider,
427 target,
428 config,
429 runtime_status,
430 )
431 .map(|picker| {
432 picker
433 .with_locale(app.ui_locale)
434 .with_provider_health(&app.provider_health)
435 })
436 {
437 *config = previous_config;
438 app.view_stack.push(picker);
439 app.status_message = Some(format!(
440 "{} needs a key or local runtime — enter one to switch.",
441 target.display_name()
442 ));
443 app.needs_redraw = true;
444 return false;
445 }
446 }
447 *config = previous_config;
448 app.add_message(HistoryCell::System {
449 content: format!(
450 "Cannot switch to {}: {reason}\nProvider unchanged ({}).",
451 requested_identity, previous_identity
452 ),
453 });
454 app.status_message = Some(format!(
455 "Route rejected before provider switch: {}.",
456 target.as_str()
457 ));
458 return false;
459 }
460 };
461 let validated_route = match resolved_route.validate() {
462 Ok(route) => route,
463 Err(err) => {
464 app.pending_provider_switch = None;
465 *config = previous_config;
466 app.add_message(HistoryCell::System {
467 content: format!(
468 "Failed to switch provider to {}: {err}\nProvider unchanged ({}).",
469 requested_identity, previous_identity
470 ),
471 });
472 return false;
473 }
474 };
475 let target_identity_record = validated_route.identity.clone();
476 let target_identity = target_identity_record.key.clone();
477 let resolved_endpoint = validated_route.candidate.endpoint().base_url.clone();
478 let route_limits = validated_route.candidate.limits();
479 let context_window_source = validated_route.context_window.source;
480 let new_model = validated_route.model.clone();
481 *config = *validated_route.config;
482
483 let new_base_url = resolved_endpoint;
484 let new_endpoint = display_base_url_host(&new_base_url);
485 let cache_scope_changed = previous_provider != target
486 || previous_identity != target_identity
487 || previous_model != new_model;
488 app.set_provider_identity_record(target_identity_record);
489 app.billing_presentation = crate::route_billing::for_route(config, target);
490 app.max_subagents = config
491 .max_subagents_for_provider(target)
492 .clamp(1, crate::config::MAX_SUBAGENTS);
493 app.provider_chain = target
494 .kind()
495 .map(|kind| codewhale_config::ProviderChain::new(kind, &config.fallback_providers))
496 .filter(|chain| chain.providers().len() > 1);
497 app.last_fallback_reason = None;
498 app.model_ids_passthrough = config.model_ids_pass_through();
499 app.set_model_selection(new_model.clone());
500 app.apply_provider_switch_reasoning_effort(target, &new_base_url, model_override.as_deref());
501 app.set_active_context_window_override(config.context_window_for_provider_config(target));
502 app.set_active_route_resolution(new_base_url.clone(), route_limits, context_window_source);
503 if model_override.is_some() {
504 app.provider_models
505 .insert(target_identity.clone(), new_model.clone());
506 app.enable_provider_model(&target_identity, &new_model);
507 }
508 app.update_model_compaction_budget();
509 if cache_scope_changed {
510 app.clear_model_scoped_telemetry();
511 } else {
512 app.session.last_prompt_tokens = None;
513 app.session.last_completion_tokens = None;
514 app.session.last_output_throughput = None;
515 }
516
517 let _ = engine_handle.send(Op::Shutdown).await;
518 let engine_config = build_engine_config(app, config);
519 *engine_handle = spawn_tui_engine(engine_config, config);
520 // A successful in-session switch must refresh the same key-scoped live
521 // catalog as startup. TelecomJS is currently the only provider using this
522 // seam; failures preserve the existing/static rows.
523 crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
524
525 if !app.api_messages.is_empty() {
526 let _ = engine_handle
527 .send(Op::SyncSession {
528 session_id: app.current_session_id.clone(),
529 messages: app.api_messages.clone(),
530 system_prompt: app.system_prompt.clone(),
531 system_prompt_override: false,
532 model: app.model.clone(),
533 workspace: app.workspace.clone(),
534 mode: app.mode,
535 })
536 .await;
537 }
538 let _ = engine_handle
539 .send(Op::SetCompaction {
540 config: app.compaction_config(),
541 })
542 .await;
543
544 // Route changes are temporary by default: nothing is written here. The
545 // route-save prompt offers the explicit persistence choices, so a
546 // workspace's config file can never be silently rewritten by a switch
547 // made in another folder.
548 app.note_session_route_change(&target_identity, &new_model);
549 let persist_warning: Option<String> = None;
550
551 let mut switch_summary = format!(
552 "Provider switched: {} → {}",
553 previous_identity, target_identity,
554 );
555 switch_summary.push(char::from(10));
556 switch_summary.push_str(&format!("Model: {previous_model} → {new_model}"));
557 switch_summary.push(char::from(10));
558 switch_summary.push_str(&format!("Endpoint: {new_endpoint}"));
559 if let Some(ref warning) = persist_warning {
560 switch_summary.push(char::from(10));
561 switch_summary.push_str(warning);
562 }
563 app.add_message(HistoryCell::System {
564 content: switch_summary,
565 });
566
567 let mut status_message = format!("Provider: {target_identity} via {new_endpoint}");
568 let persisted = persist_warning.is_none();
569 if persist_warning.is_some() {
570 status_message.push_str(" (not fully persisted)");
571 }
572 app.status_message = Some(status_message);
573 // #3927: activating a route is the single event that retires the
574 // explore-offline label. Nothing time-based or screen-based clears it.
575 onboarding::clear_offline_explore_on_route_activation(app);
576 if persisted {
577 record_provider_model_setup_progress(app, config);
578 }
579 true
580 }
581
582 pub(crate) fn display_base_url_host(base_url: &str) -> String {
583 let without_scheme = base_url
584 .split_once("://")
585 .map_or(base_url, |(_, rest)| rest);
586 without_scheme
587 .split('/')
588 .next()
589 .filter(|host| !host.is_empty())
590 .unwrap_or(base_url)
591 .to_string()
592 }
593
594 pub(crate) fn sync_config_provider_from_app(config: &mut Config, app: &App) {
595 config.provider = Some(app.provider_identity_for_persistence().to_string());
596 }
597
598 pub(crate) fn provider_picker_model_override(
599 app: &App,
600 config: &Config,
601 provider: ApiProvider,
602 ) -> Option<String> {
603 (app.api_provider == provider
604 && app.provider_identity_for_persistence() == config.provider_identity_for(provider))
605 .then(|| app.model.clone())
606 }
607
608 pub(crate) async fn query_provider_runtime_status(
609 engine_handle: &EngineHandle,
610 ) -> Option<ProviderRuntimeStatus> {
611 tokio::time::timeout(
612 Duration::from_millis(100),
613 engine_handle.get_provider_runtime_status(),
614 )
615 .await
616 .ok()
617 .and_then(|result| result.ok())
618 }
619
620 pub(crate) fn mcp_reload_summary(snapshot: &crate::mcp::McpManagerSnapshot) -> String {
621 let connected = snapshot
622 .servers
623 .iter()
624 .filter(|server| server.connected)
625 .count();
626 let failed = snapshot
627 .servers
628 .iter()
629 .filter(|server| server.enabled && server.error.is_some())
630 .count();
631 let disabled = snapshot
632 .servers
633 .iter()
634 .filter(|server| !server.enabled)
635 .count();
636 format!(
637 "MCP tool pool reloaded in process: {connected} connected, {failed} failed, {disabled} disabled. The next model turn uses this catalog."
638 )
639 }
640
641 pub(crate) fn mcp_ui_action_refreshes_discovery(action: &crate::tui::app::McpUiAction) -> bool {
642 matches!(
643 action,
644 crate::tui::app::McpUiAction::Show
645 | crate::tui::app::McpUiAction::Validate
646 | crate::tui::app::McpUiAction::Login { .. }
647 | crate::tui::app::McpUiAction::Logout { .. }
648 | crate::tui::app::McpUiAction::ImportList
649 | crate::tui::app::McpUiAction::ImportApprove { .. }
650 )
651 }
652
653 pub(crate) fn mcp_import_consent_path() -> PathBuf {
654 codewhale_config::codewhale_home()
655 .unwrap_or_else(|_| PathBuf::from("."))
656 .join("mcp-import-consent.json")
657 }
658
659 pub(crate) fn mcp_external_import_status_text(workspace: &std::path::Path) -> String {
660 use crate::mcp::external_import::{discover_external_sources, format_candidates_for_display};
661 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("."));
662 let market_path = codewhale_config::codewhale_home()
663 .ok()
664 .map(|h| h.join("mcp-marketplace.json"));
665 let markets: Vec<PathBuf> = market_path.into_iter().collect();
666 let all = discover_external_sources(&home, workspace, &markets);
667 let mut body = format_candidates_for_display(&all);
668 body.push_str("\n\nConfigured managed connectors stay in your mcp.json; external sources never auto-merge.");
669 body
670 }
671
672 pub(crate) fn mcp_import_apply(
673 workspace: &std::path::Path,
674 mcp_path: &std::path::Path,
675 name: &str,
676 approve: bool,
677 ) -> anyhow::Result<String> {
678 use crate::mcp::external_import::{
679 ImportDecision, apply_approved, discover_external_sources, load_consent_store,
680 merge_approved_into_config, record_decisions, save_consent_store,
681 };
682 use std::collections::HashMap;
683 use std::time::{SystemTime, UNIX_EPOCH};
684
685 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("."));
686 let market_path = codewhale_config::codewhale_home()
687 .ok()
688 .map(|h| h.join("mcp-marketplace.json"));
689 let markets: Vec<PathBuf> = market_path.into_iter().collect();
690 let all = discover_external_sources(&home, workspace, &markets);
691 let candidate = all
692 .iter()
693 .find(|c| c.name.eq_ignore_ascii_case(name))
694 .ok_or_else(|| {
695 anyhow::anyhow!(
696 "No external MCP candidate named '{name}'. Run /mcp import to list sources with provenance."
697 )
698 })?;
699
700 if approve && candidate.hard_blocked {
701 anyhow::bail!(
702 "Refusing to import '{}': {} (enabled=false is a hard block)",
703 candidate.name,
704 candidate.block_reason.as_deref().unwrap_or("hard blocked")
705 );
706 }
707
708 let mut decisions = HashMap::new();
709 decisions.insert(
710 candidate.name.clone(),
711 if approve {
712 ImportDecision::Approve
713 } else {
714 ImportDecision::Decline
715 },
716 );
717
718 let mut store = load_consent_store(&mcp_import_consent_path());
719 let now = SystemTime::now()
720 .duration_since(UNIX_EPOCH)
721 .map(|d| d.as_secs())
722 .unwrap_or(0);
723 record_decisions(&mut store, std::slice::from_ref(candidate), &decisions, now);
724 save_consent_store(&mcp_import_consent_path(), &store)?;
725
726 if !approve {
727 return Ok(format!(
728 "Declined external MCP '{}' from {} (hash {}). Will not re-prompt until the source content changes.",
729 candidate.name,
730 candidate.source_path.display(),
731 &candidate.content_hash[..12.min(candidate.content_hash.len())]
732 ));
733 }
734
735 let approved = apply_approved(std::slice::from_ref(candidate), &decisions);
736 let mut cfg = crate::mcp::load_config(mcp_path)?;
737 let inserted = merge_approved_into_config(&mut cfg, &approved);
738 if inserted.is_empty() {
739 return Ok(format!(
740 "MCP '{}' was already present in {} or could not be merged. Provenance: {} @ {}",
741 candidate.name,
742 mcp_path.display(),
743 candidate.source_kind.as_str(),
744 candidate.source_path.display()
745 ));
746 }
747 crate::mcp::save_config(mcp_path, &cfg)?;
748 Ok(format!(
749 "Imported managed MCP connector '{}' into {} (provenance: {} @ {}, hash {}). Run /mcp reload to connect after review.",
750 candidate.name,
751 mcp_path.display(),
752 candidate.source_kind.as_str(),
753 candidate.source_path.display(),
754 &candidate.content_hash[..12.min(candidate.content_hash.len())]
755 ))
756 }
757
758 pub(crate) fn clear_active_provider_api_key_from_memory(app: &App, config: &mut Config) {
759 let active_identity = app.provider_identity_for_persistence();
760 let clears_legacy_root = matches!(
761 app.api_provider,
762 ApiProvider::Deepseek | ApiProvider::DeepseekCN
763 ) || (app.api_provider == ApiProvider::Custom
764 && active_identity == ApiProvider::Custom.as_str()
765 && config.uses_legacy_literal_custom_route());
766 if clears_legacy_root {
767 config.api_key = None;
768 }
769 config.set_provider_api_key_override(app.api_provider, None);
770 if app.api_provider == ApiProvider::Xai {
771 let entry = config.provider_config_for_mut(ApiProvider::Xai);
772 entry.auth_mode = None;
773 entry.oauth_credential_generation = None;
774 entry.external_credentials = None;
775 }
776 }
777
778 pub(crate) fn record_provider_model_setup_progress(app: &mut App, config: &Config) {
779 if let Err(err) = crate::tui::setup::record_provider_model_setup_state_for_app(app, config) {
780 let note = format!("Setup provider/model state was not saved: {err}");
781 if let Some(status) = app.status_message.as_mut() {
782 status.push_str(" · ");
783 status.push_str(&note);
784 } else {
785 app.status_message = Some(note.clone());
786 }
787 app.add_message(HistoryCell::System { content: note });
788 }
789 }
790
791 /// Persist the typed API key to `~/.codewhale/config.toml`, refresh the
792 /// in-memory config so the engine can see it, then switch to the provider.
793 pub(crate) fn set_active_custom_provider_in_memory(config: &mut Config, provider_id: &str) {
794 let provider_id = provider_id.trim();
795 if provider_id.is_empty() {
796 return;
797 }
798 config.provider = Some(provider_id.to_string());
799 config
800 .providers
801 .get_or_insert_with(ProvidersConfig::default)
802 .custom
803 .entry(provider_id.to_string())
804 .or_default();
805 }
806
807 pub(crate) fn picker_provider_identity(
808 config: &Config,
809 provider: ApiProvider,
810 provider_id: Option<&str>,
811 ) -> Result<crate::config::ProviderIdentity, String> {
812 let identity = match provider_id {
813 Some(provider_id) => config
814 .resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider_id))?,
815 None if provider == ApiProvider::Custom => config.active_provider_identity(provider)?,
816 None => config.resolve_persisted_provider_identity(
817 Some(provider.as_str()),
818 Some(provider.as_str()),
819 )?,
820 };
821 if identity.provider != provider {
822 return Err(format!(
823 "provider picker identity '{}' resolved as {}, not {}",
824 identity.key,
825 identity.provider.as_str(),
826 provider.as_str()
827 ));
828 }
829 Ok(identity)
830 }
831
832 #[cfg(test)]
833 pub(crate) fn provider_verification_error_category(
834 reason: &str,
835 ) -> crate::error_taxonomy::ErrorCategory {
836 let lower = reason.to_ascii_lowercase();
837 if lower.contains("http 401") || lower.contains("status 401") {
838 crate::error_taxonomy::ErrorCategory::Authentication
839 } else if lower.contains("http 403") || lower.contains("status 403") {
840 crate::error_taxonomy::ErrorCategory::Authorization
841 } else if ["500", "502", "503", "504"]
842 .iter()
843 .any(|status| lower.contains(&format!("http {status}")))
844 {
845 crate::error_taxonomy::ErrorCategory::Network
846 } else {
847 crate::error_taxonomy::classify_error_message(reason)
848 }
849 }
850
850 lines RUST