Skip to main content

reflex/semantic/
configure.rs

1//! Interactive TUI configuration wizard for AI provider setup
2
3use anyhow::{Context, Result};
4use crossterm::{
5    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
6    execute,
7    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
8};
9use ratatui::{
10    backend::CrosstermBackend,
11    layout::{Alignment, Constraint, Direction, Layout},
12    style::{Color, Modifier, Style},
13    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
14    Frame, Terminal,
15};
16use std::collections::HashMap;
17use std::io::{self, Stdout};
18
19/// Available AI providers
20const PROVIDERS: &[&str] = &["openai", "anthropic", "openrouter", "openai-compatible"];
21
22// OpenAI and Anthropic model lists are fetched dynamically from each provider's
23// /v1/models endpoint at wizard runtime (see fetch_openai_models_blocking,
24// fetch_anthropic_models_blocking). These small fallback lists are only used
25// when the live fetch fails (offline, key revoked, regional outage), so the
26// wizard remains usable without a network connection.
27const OPENAI_FALLBACK_MODELS: &[&str] = &[
28    "gpt-5.1",
29    "gpt-5.1-mini",
30    "gpt-5",
31    "gpt-5-mini",
32    "gpt-4.1",
33    "gpt-4.1-mini",
34    "gpt-4o",
35    "gpt-4o-mini",
36];
37const ANTHROPIC_FALLBACK_MODELS: &[&str] = &[
38    "claude-sonnet-4-5",
39    "claude-haiku-4-5",
40    "claude-sonnet-4",
41];
42use crate::semantic::providers::openrouter::OpenRouterModel;
43
44/// Sort strategies for OpenRouter provider routing
45const OPENROUTER_SORT_STRATEGIES: &[(&str, &str)] = &[
46    ("price", "Cheapest provider for the model"),
47    ("latency", "Fastest response time (lowest latency)"),
48    ("throughput", "Highest tokens per second"),
49];
50
51/// Wizard screen states
52#[derive(Debug, Clone, PartialEq)]
53enum WizardScreen {
54    ProviderSelection,
55    BaseUrlInput,
56    ApiKeyInput,
57    FetchingModels,
58    ModelSelection,
59    ModelTextInput,
60    SortStrategySelection,
61    ConnectivityTest,
62    Result { success: bool, message: String },
63}
64
65/// Load existing API key for a provider from ~/.reflex/config.toml
66fn load_existing_api_key(provider: &str) -> Option<String> {
67    match crate::semantic::config::get_api_key(provider) {
68        Ok(key) if !key.is_empty() => {
69            log::debug!("Found existing API key for {}", provider);
70            Some(key)
71        }
72        _ => {
73            log::debug!("No existing API key found for {}", provider);
74            None
75        }
76    }
77}
78
79/// Load existing base URL for the openai-compatible provider
80fn load_existing_base_url() -> Option<String> {
81    crate::semantic::config::get_provider_options("openai-compatible")
82        .and_then(|opts| opts.get("base_url").cloned())
83        .filter(|s| !s.is_empty())
84}
85
86/// Load existing model preference for the openai-compatible provider
87fn load_existing_compatible_model() -> Option<String> {
88    crate::semantic::config::get_user_model("openai-compatible")
89}
90
91/// Mask API key for display (show first 7 and last 4 characters)
92fn mask_api_key(key: &str) -> String {
93    if key.len() <= 11 {
94        // Too short to mask meaningfully
95        return "*".repeat(key.len());
96    }
97
98    let start = &key[..7];
99    let end = &key[key.len() - 4..];
100    format!("{}...{}", start, end)
101}
102
103/// Main configuration wizard state
104pub struct ConfigWizard {
105    screen: WizardScreen,
106    selected_provider_idx: usize,
107    api_key: String,
108    api_key_cursor: usize,
109    selected_model_idx: usize,
110    selected_sort_idx: usize,
111    error_message: Option<String>,
112    existing_api_key: Option<String>,
113    /// Dynamically fetched models (OpenRouter)
114    fetched_models: Vec<OpenRouterModel>,
115    /// Dynamically fetched plain model IDs (OpenAI, Anthropic)
116    fetched_dynamic_models: Vec<String>,
117    /// Current search/filter text for model selection
118    model_filter: String,
119    /// Base URL for openai-compatible endpoints
120    base_url: String,
121    base_url_cursor: usize,
122    /// Free-text model name for openai-compatible (since we cannot enumerate)
123    model_text: String,
124    model_text_cursor: usize,
125    /// Previously-saved base URL (used to pre-populate the wizard on re-run)
126    existing_base_url: Option<String>,
127    /// Previously-saved openai-compatible model name
128    existing_compatible_model: Option<String>,
129}
130
131impl ConfigWizard {
132    pub fn new() -> Self {
133        Self {
134            screen: WizardScreen::ProviderSelection,
135            selected_provider_idx: 0,
136            api_key: String::new(),
137            api_key_cursor: 0,
138            selected_model_idx: 0,
139            selected_sort_idx: 0,
140            error_message: None,
141            existing_api_key: None,
142            fetched_models: Vec::new(),
143            fetched_dynamic_models: Vec::new(),
144            model_filter: String::new(),
145            base_url: String::new(),
146            base_url_cursor: 0,
147            model_text: String::new(),
148            model_text_cursor: 0,
149            existing_base_url: None,
150            existing_compatible_model: None,
151        }
152    }
153
154    /// Get the currently selected provider
155    fn selected_provider(&self) -> &str {
156        PROVIDERS[self.selected_provider_idx]
157    }
158
159    /// True for providers whose model list supports a typed text filter.
160    /// OpenAI/Anthropic/OpenRouter are dynamically fetched and may be long;
161    /// type-to-filter narrows the displayed list.
162    fn supports_filter(&self) -> bool {
163        matches!(
164            self.selected_provider(),
165            "openrouter" | "openai" | "anthropic"
166        )
167    }
168
169    /// Get available static models for the current provider.
170    ///
171    /// Returns empty for every provider since openai/anthropic/openrouter all
172    /// fetch dynamically and openai-compatible uses free-text input. Kept as a
173    /// hook in case a future provider ships with a static catalog.
174    fn static_models(&self) -> &'static [&'static str] {
175        &[]
176    }
177
178    /// Get filtered model IDs for display (applies search filter for dynamic providers).
179    fn filtered_model_ids(&self) -> Vec<String> {
180        let filter = self.model_filter.to_lowercase();
181        match self.selected_provider() {
182            "openrouter" => self
183                .fetched_models
184                .iter()
185                .filter(|m| {
186                    if filter.is_empty() {
187                        return true;
188                    }
189                    m.id.to_lowercase().contains(&filter)
190                        || m.name.to_lowercase().contains(&filter)
191                })
192                .map(|m| m.id.clone())
193                .collect(),
194            "openai" | "anthropic" => self
195                .fetched_dynamic_models
196                .iter()
197                .filter(|id| filter.is_empty() || id.to_lowercase().contains(&filter))
198                .cloned()
199                .collect(),
200            _ => self.static_models().iter().map(|s| s.to_string()).collect(),
201        }
202    }
203
204    /// Get the currently selected sort strategy (OpenRouter only)
205    fn selected_sort(&self) -> &str {
206        OPENROUTER_SORT_STRATEGIES[self.selected_sort_idx].0
207    }
208
209    /// Get the currently selected model
210    fn selected_model(&self) -> String {
211        let models = self.filtered_model_ids();
212        if self.selected_model_idx < models.len() {
213            models[self.selected_model_idx].clone()
214        } else if !models.is_empty() {
215            models[0].clone()
216        } else {
217            String::new()
218        }
219    }
220
221    /// Get the OpenRouterModel info for a model ID in the filtered list
222    fn filtered_openrouter_model(&self, idx: usize) -> Option<&OpenRouterModel> {
223        let filter = self.model_filter.to_lowercase();
224        self.fetched_models
225            .iter()
226            .filter(|m| {
227                if filter.is_empty() {
228                    return true;
229                }
230                m.id.to_lowercase().contains(&filter)
231                    || m.name.to_lowercase().contains(&filter)
232            })
233            .nth(idx)
234    }
235
236    /// Handle keyboard input based on current screen
237    fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
238        // Handle Ctrl+C globally to exit wizard
239        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
240            return Ok(true);
241        }
242
243        match &self.screen {
244            WizardScreen::ProviderSelection => self.handle_provider_selection_key(key),
245            WizardScreen::BaseUrlInput => self.handle_base_url_input_key(key),
246            WizardScreen::ApiKeyInput => self.handle_api_key_input_key(key),
247            WizardScreen::FetchingModels => Ok(false), // No input during fetch
248            WizardScreen::ModelSelection => self.handle_model_selection_key(key),
249            WizardScreen::ModelTextInput => self.handle_model_text_input_key(key),
250            WizardScreen::SortStrategySelection => self.handle_sort_strategy_key(key),
251            WizardScreen::ConnectivityTest => Ok(false), // No input during test
252            WizardScreen::Result { .. } => {
253                // Any key exits on result screen
254                if key.code == KeyCode::Enter || key.code == KeyCode::Char('q') {
255                    return Ok(true);
256                }
257                Ok(false)
258            }
259        }
260    }
261
262    /// Handle keys for provider selection screen
263    fn handle_provider_selection_key(&mut self, key: KeyEvent) -> Result<bool> {
264        match key.code {
265            KeyCode::Up | KeyCode::Char('k') => {
266                if self.selected_provider_idx > 0 {
267                    self.selected_provider_idx -= 1;
268                }
269            }
270            KeyCode::Down | KeyCode::Char('j') => {
271                if self.selected_provider_idx < PROVIDERS.len() - 1 {
272                    self.selected_provider_idx += 1;
273                }
274            }
275            KeyCode::Enter => {
276                // Check if API key already exists for this provider
277                self.existing_api_key = load_existing_api_key(self.selected_provider());
278
279                if self.selected_provider() == "openai-compatible" {
280                    // Pre-populate base URL and model from any prior config
281                    self.existing_base_url = load_existing_base_url();
282                    self.existing_compatible_model = load_existing_compatible_model();
283                    self.base_url = self.existing_base_url.clone().unwrap_or_default();
284                    self.base_url_cursor = self.base_url.len();
285                    self.model_text = self.existing_compatible_model.clone().unwrap_or_default();
286                    self.model_text_cursor = self.model_text.len();
287                    self.error_message = None;
288                    self.screen = WizardScreen::BaseUrlInput;
289                } else {
290                    // Move to API key input for the standard providers
291                    self.screen = WizardScreen::ApiKeyInput;
292                    self.api_key.clear();
293                    self.api_key_cursor = 0;
294                }
295            }
296            KeyCode::Esc | KeyCode::Char('q') => {
297                return Ok(true); // Exit wizard
298            }
299            _ => {}
300        }
301        Ok(false)
302    }
303
304    /// Handle keys for base URL input screen (openai-compatible only)
305    fn handle_base_url_input_key(&mut self, key: KeyEvent) -> Result<bool> {
306        match key.code {
307            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
308                self.base_url.insert(self.base_url_cursor, c);
309                self.base_url_cursor += 1;
310            }
311            KeyCode::Backspace => {
312                if self.base_url_cursor > 0 {
313                    self.base_url_cursor -= 1;
314                    self.base_url.remove(self.base_url_cursor);
315                }
316            }
317            KeyCode::Delete => {
318                if self.base_url_cursor < self.base_url.len() {
319                    self.base_url.remove(self.base_url_cursor);
320                }
321            }
322            KeyCode::Left => {
323                if self.base_url_cursor > 0 {
324                    self.base_url_cursor -= 1;
325                }
326            }
327            KeyCode::Right => {
328                if self.base_url_cursor < self.base_url.len() {
329                    self.base_url_cursor += 1;
330                }
331            }
332            KeyCode::Home => {
333                self.base_url_cursor = 0;
334            }
335            KeyCode::End => {
336                self.base_url_cursor = self.base_url.len();
337            }
338            KeyCode::Enter => {
339                let trimmed = self.base_url.trim().trim_end_matches('/');
340                if trimmed.is_empty() {
341                    self.error_message = Some("Base URL cannot be empty".to_string());
342                } else if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
343                    self.error_message =
344                        Some("Base URL must start with http:// or https://".to_string());
345                } else {
346                    self.base_url = trimmed.to_string();
347                    self.base_url_cursor = self.base_url.len();
348                    self.error_message = None;
349                    self.screen = WizardScreen::ApiKeyInput;
350                    self.api_key.clear();
351                    self.api_key_cursor = 0;
352                }
353            }
354            KeyCode::Esc => {
355                self.error_message = None;
356                self.screen = WizardScreen::ProviderSelection;
357            }
358            _ => {}
359        }
360        Ok(false)
361    }
362
363    /// Handle keys for API key input screen
364    fn handle_api_key_input_key(&mut self, key: KeyEvent) -> Result<bool> {
365        match key.code {
366            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
367                self.api_key.insert(self.api_key_cursor, c);
368                self.api_key_cursor += 1;
369            }
370            KeyCode::Backspace => {
371                if self.api_key_cursor > 0 {
372                    self.api_key_cursor -= 1;
373                    self.api_key.remove(self.api_key_cursor);
374                }
375            }
376            KeyCode::Delete => {
377                if self.api_key_cursor < self.api_key.len() {
378                    self.api_key.remove(self.api_key_cursor);
379                }
380            }
381            KeyCode::Left => {
382                if self.api_key_cursor > 0 {
383                    self.api_key_cursor -= 1;
384                }
385            }
386            KeyCode::Right => {
387                if self.api_key_cursor < self.api_key.len() {
388                    self.api_key_cursor += 1;
389                }
390            }
391            KeyCode::Home => {
392                self.api_key_cursor = 0;
393            }
394            KeyCode::End => {
395                self.api_key_cursor = self.api_key.len();
396            }
397            KeyCode::Enter => {
398                let provider = self.selected_provider();
399                let is_compatible = provider == "openai-compatible";
400
401                // Determine the next screen for the chosen provider
402                let next_screen = match provider {
403                    "openrouter" | "openai" | "anthropic" => WizardScreen::FetchingModels,
404                    "openai-compatible" => WizardScreen::ModelTextInput,
405                    _ => WizardScreen::ModelSelection,
406                };
407
408                if self.api_key.is_empty() {
409                    if let Some(ref existing_key) = self.existing_api_key {
410                        log::debug!("Keeping existing API key for {}", provider);
411                        self.api_key = existing_key.clone();
412                        self.error_message = None;
413                        self.selected_model_idx = 0;
414                        self.model_filter.clear();
415                        self.screen = next_screen;
416                    } else if is_compatible {
417                        // Local servers (LMStudio, Ollama, llama.cpp) often don't need a key
418                        log::debug!("Proceeding without API key for openai-compatible");
419                        self.error_message = None;
420                        self.selected_model_idx = 0;
421                        self.model_filter.clear();
422                        self.screen = next_screen;
423                    } else {
424                        self.error_message = Some("API key cannot be empty".to_string());
425                    }
426                } else {
427                    self.error_message = None;
428                    self.selected_model_idx = 0;
429                    self.model_filter.clear();
430                    self.screen = next_screen;
431                }
432            }
433            KeyCode::Esc => {
434                // openai-compatible has BaseUrlInput as the previous screen
435                if self.selected_provider() == "openai-compatible" {
436                    self.screen = WizardScreen::BaseUrlInput;
437                } else {
438                    self.screen = WizardScreen::ProviderSelection;
439                }
440            }
441            _ => {}
442        }
443        Ok(false)
444    }
445
446    /// Handle keys for model selection screen
447    fn handle_model_selection_key(&mut self, key: KeyEvent) -> Result<bool> {
448        let is_openrouter = self.selected_provider() == "openrouter";
449        let supports_filter = self.supports_filter();
450        let model_count = self.filtered_model_ids().len();
451
452        match key.code {
453            KeyCode::Up => {
454                if self.selected_model_idx > 0 {
455                    self.selected_model_idx -= 1;
456                }
457            }
458            KeyCode::Down => {
459                if model_count > 0 && self.selected_model_idx < model_count - 1 {
460                    self.selected_model_idx += 1;
461                }
462            }
463            // j/k vim navigation only works when typing-to-filter is disabled,
464            // otherwise those characters would always be swallowed as filter input.
465            KeyCode::Char('k') if !supports_filter => {
466                if self.selected_model_idx > 0 {
467                    self.selected_model_idx -= 1;
468                }
469            }
470            KeyCode::Char('j') if !supports_filter => {
471                if model_count > 0 && self.selected_model_idx < model_count - 1 {
472                    self.selected_model_idx += 1;
473                }
474            }
475            KeyCode::Char(c) if supports_filter && !key.modifiers.contains(KeyModifiers::CONTROL) => {
476                self.model_filter.push(c);
477                self.selected_model_idx = 0;
478            }
479            KeyCode::Backspace if supports_filter => {
480                self.model_filter.pop();
481                self.selected_model_idx = 0;
482            }
483            KeyCode::Enter => {
484                if model_count == 0 {
485                    // No models to select
486                    return Ok(false);
487                }
488                if is_openrouter {
489                    self.selected_sort_idx = 0;
490                    self.screen = WizardScreen::SortStrategySelection;
491                } else {
492                    self.screen = WizardScreen::ConnectivityTest;
493                }
494            }
495            KeyCode::Esc => {
496                self.model_filter.clear();
497                self.screen = WizardScreen::ApiKeyInput;
498            }
499            _ => {}
500        }
501        Ok(false)
502    }
503
504    /// Handle keys for free-text model input (openai-compatible only)
505    fn handle_model_text_input_key(&mut self, key: KeyEvent) -> Result<bool> {
506        match key.code {
507            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
508                self.model_text.insert(self.model_text_cursor, c);
509                self.model_text_cursor += 1;
510            }
511            KeyCode::Backspace => {
512                if self.model_text_cursor > 0 {
513                    self.model_text_cursor -= 1;
514                    self.model_text.remove(self.model_text_cursor);
515                }
516            }
517            KeyCode::Delete => {
518                if self.model_text_cursor < self.model_text.len() {
519                    self.model_text.remove(self.model_text_cursor);
520                }
521            }
522            KeyCode::Left => {
523                if self.model_text_cursor > 0 {
524                    self.model_text_cursor -= 1;
525                }
526            }
527            KeyCode::Right => {
528                if self.model_text_cursor < self.model_text.len() {
529                    self.model_text_cursor += 1;
530                }
531            }
532            KeyCode::Home => {
533                self.model_text_cursor = 0;
534            }
535            KeyCode::End => {
536                self.model_text_cursor = self.model_text.len();
537            }
538            KeyCode::Enter => {
539                if self.model_text.trim().is_empty() {
540                    self.error_message = Some("Model name cannot be empty".to_string());
541                } else {
542                    self.error_message = None;
543                    self.screen = WizardScreen::ConnectivityTest;
544                }
545            }
546            KeyCode::Esc => {
547                self.error_message = None;
548                self.screen = WizardScreen::ApiKeyInput;
549            }
550            _ => {}
551        }
552        Ok(false)
553    }
554
555    /// Handle keys for sort strategy selection screen (OpenRouter only)
556    fn handle_sort_strategy_key(&mut self, key: KeyEvent) -> Result<bool> {
557        match key.code {
558            KeyCode::Up | KeyCode::Char('k') => {
559                if self.selected_sort_idx > 0 {
560                    self.selected_sort_idx -= 1;
561                }
562            }
563            KeyCode::Down | KeyCode::Char('j') => {
564                if self.selected_sort_idx < OPENROUTER_SORT_STRATEGIES.len() - 1 {
565                    self.selected_sort_idx += 1;
566                }
567            }
568            KeyCode::Enter => {
569                self.screen = WizardScreen::ConnectivityTest;
570            }
571            KeyCode::Esc => {
572                // Go back to model selection
573                self.screen = WizardScreen::ModelSelection;
574            }
575            _ => {}
576        }
577        Ok(false)
578    }
579
580    /// Render the current screen
581    fn render(&mut self, frame: &mut Frame) {
582        // Clone screen to avoid borrow conflict with &mut self render methods
583        let screen = self.screen.clone();
584        match &screen {
585            WizardScreen::ProviderSelection => self.render_provider_selection(frame),
586            WizardScreen::BaseUrlInput => self.render_base_url_input(frame),
587            WizardScreen::ApiKeyInput => self.render_api_key_input(frame),
588            WizardScreen::FetchingModels => self.render_fetching_models(frame),
589            WizardScreen::ModelSelection => self.render_model_selection(frame),
590            WizardScreen::ModelTextInput => self.render_model_text_input(frame),
591            WizardScreen::SortStrategySelection => self.render_sort_strategy_selection(frame),
592            WizardScreen::ConnectivityTest => self.render_connectivity_test(frame),
593            WizardScreen::Result { success, message } => {
594                self.render_result(frame, *success, message)
595            }
596        }
597    }
598
599    /// Render provider selection screen
600    fn render_provider_selection(&mut self, frame: &mut Frame) {
601        let chunks = Layout::default()
602            .direction(Direction::Vertical)
603            .margin(2)
604            .constraints([
605                Constraint::Length(3),
606                Constraint::Min(0),
607                Constraint::Length(3),
608            ])
609            .split(frame.area());
610
611        // Title
612        let title = Paragraph::new("Reflex AI Configuration Wizard")
613            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
614            .alignment(Alignment::Center)
615            .block(Block::default().borders(Borders::ALL));
616        frame.render_widget(title, chunks[0]);
617
618        // Provider list
619        let providers: Vec<ListItem> = PROVIDERS
620            .iter()
621            .map(|provider| {
622                let provider_display = match *provider {
623                    "openrouter" => format!("{} (200+ models)", provider),
624                    _ => provider.to_string(),
625                };
626
627                ListItem::new(provider_display)
628            })
629            .collect();
630
631        let list = List::new(providers)
632            .block(
633                Block::default()
634                    .borders(Borders::ALL)
635                    .title("Select AI Provider (↑/↓ to navigate, Enter to select, Esc/q/Ctrl+C to quit)"),
636            )
637            .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
638            .highlight_symbol("> ");
639
640        let mut list_state = ListState::default().with_selected(Some(self.selected_provider_idx));
641        frame.render_stateful_widget(list, chunks[1], &mut list_state);
642
643        // Help text
644        let help = Paragraph::new("Use arrow keys or j/k to navigate, Enter to select, Esc/q/Ctrl+C to quit")
645            .style(Style::default().fg(Color::DarkGray))
646            .alignment(Alignment::Center);
647        frame.render_widget(help, chunks[2]);
648    }
649
650    /// Render API key input screen
651    fn render_api_key_input(&mut self, frame: &mut Frame) {
652        let chunks = Layout::default()
653            .direction(Direction::Vertical)
654            .margin(2)
655            .constraints([
656                Constraint::Length(3),
657                Constraint::Length(5),
658                Constraint::Min(0),
659                Constraint::Length(3),
660            ])
661            .split(frame.area());
662
663        // Title
664        let title = Paragraph::new(format!(
665            "Configure {} API Key",
666            self.selected_provider()
667        ))
668        .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
669        .alignment(Alignment::Center)
670        .block(Block::default().borders(Borders::ALL));
671        frame.render_widget(title, chunks[0]);
672
673        // API key input (masked)
674        let masked_key = "*".repeat(self.api_key.len());
675        let input_text = if self.api_key_cursor < masked_key.len() {
676            format!("{}█{}", &masked_key[..self.api_key_cursor], &masked_key[self.api_key_cursor..])
677        } else {
678            format!("{}█", masked_key)
679        };
680
681        let input = Paragraph::new(input_text)
682            .style(Style::default().fg(Color::Yellow))
683            .block(
684                Block::default()
685                    .borders(Borders::ALL)
686                    .title(format!("Enter API Key for {}", self.selected_provider())),
687            );
688        frame.render_widget(input, chunks[1]);
689
690        // Error message or instructions
691        let message_widget = if let Some(ref error) = self.error_message {
692            Paragraph::new(error.as_str())
693                .style(Style::default().fg(Color::Red))
694                .alignment(Alignment::Center)
695        } else if let Some(ref existing_key) = self.existing_api_key {
696            // Show masked existing key
697            let masked = mask_api_key(existing_key);
698            Paragraph::new(format!(
699                "Current API key: {}\n\
700                Press Enter to keep existing key, or type a new key to replace it\n\
701                Your API key will be securely stored in ~/.reflex/config.toml",
702                masked
703            ))
704            .style(Style::default().fg(Color::Yellow))
705            .alignment(Alignment::Center)
706        } else {
707            Paragraph::new("Your API key will be securely stored in ~/.reflex/config.toml")
708                .style(Style::default().fg(Color::Green))
709                .alignment(Alignment::Center)
710        };
711        frame.render_widget(message_widget, chunks[2]);
712
713        // Help text
714        let help = Paragraph::new("Enter to continue, Esc to go back, Ctrl+C to quit")
715            .style(Style::default().fg(Color::DarkGray))
716            .alignment(Alignment::Center);
717        frame.render_widget(help, chunks[3]);
718    }
719
720    /// Render model selection screen
721    fn render_model_selection(&mut self, frame: &mut Frame) {
722        let is_openrouter = self.selected_provider() == "openrouter";
723        let supports_filter = self.supports_filter();
724        let filtered = self.filtered_model_ids();
725        let model_count = filtered.len();
726
727        let constraints = if is_openrouter {
728            vec![
729                Constraint::Length(3),  // Title
730                Constraint::Length(3),  // Filter input
731                Constraint::Min(0),     // Model list
732                Constraint::Length(3),  // Help text
733            ]
734        } else {
735            vec![
736                Constraint::Length(3),  // Title
737                Constraint::Min(0),     // Model list
738                Constraint::Length(3),  // Help text
739            ]
740        };
741
742        let chunks = Layout::default()
743            .direction(Direction::Vertical)
744            .margin(2)
745            .constraints(constraints)
746            .split(frame.area());
747
748        // Title
749        let title_text = if is_openrouter {
750            format!("Select Model for {} ({} models)", self.selected_provider(), model_count)
751        } else {
752            format!("Select Model for {}", self.selected_provider())
753        };
754        let title = Paragraph::new(title_text)
755            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
756            .alignment(Alignment::Center)
757            .block(Block::default().borders(Borders::ALL));
758        frame.render_widget(title, chunks[0]);
759
760        // Filter input (OpenRouter only)
761        let (list_chunk, help_chunk) = if is_openrouter {
762            let filter_text = format!("{}█", self.model_filter);
763            let filter_input = Paragraph::new(filter_text)
764                .style(Style::default().fg(Color::Yellow))
765                .block(
766                    Block::default()
767                        .borders(Borders::ALL)
768                        .title("Filter (type to search)"),
769                );
770            frame.render_widget(filter_input, chunks[1]);
771            (chunks[2], chunks[3])
772        } else {
773            (chunks[1], chunks[2])
774        };
775
776        // Model list
777        if model_count == 0 && supports_filter {
778            let empty_msg = Paragraph::new("No models match filter")
779                .style(Style::default().fg(Color::DarkGray))
780                .alignment(Alignment::Center)
781                .block(Block::default().borders(Borders::ALL).title("Models"));
782            frame.render_widget(empty_msg, list_chunk);
783        } else {
784            let model_items: Vec<ListItem> = filtered
785                .iter()
786                .enumerate()
787                .map(|(idx, model_id)| {
788                    let model_display = if is_openrouter {
789                        if let Some(m) = self.filtered_openrouter_model(idx) {
790                            format!("{}  ${:.2} / ${:.2} per 1M tokens",
791                                model_id, m.prompt_price, m.completion_price)
792                        } else {
793                            model_id.to_string()
794                        }
795                    } else if idx == 0 {
796                        format!("{} (recommended)", model_id)
797                    } else {
798                        model_id.to_string()
799                    };
800
801                    ListItem::new(model_display)
802                })
803                .collect();
804
805            let list_title = if supports_filter {
806                "Models (↑/↓ to navigate, type to filter, Enter to select, Esc to go back)"
807            } else {
808                "Select Model (↑/↓ to navigate, Enter to select, Esc to go back, Ctrl+C to quit)"
809            };
810            let list = List::new(model_items)
811                .block(
812                    Block::default()
813                        .borders(Borders::ALL)
814                        .title(list_title),
815                )
816                .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
817                .highlight_symbol("> ");
818
819            let mut list_state = ListState::default().with_selected(Some(self.selected_model_idx));
820            frame.render_stateful_widget(list, list_chunk, &mut list_state);
821        }
822
823        // Help text
824        let help_text = if supports_filter {
825            "Type to filter, ↑/↓ to navigate, Enter to select, Esc to go back, Ctrl+C to quit"
826        } else {
827            "Use arrow keys or j/k to navigate, Enter to select, Esc to go back, Ctrl+C to quit"
828        };
829        let help = Paragraph::new(help_text)
830            .style(Style::default().fg(Color::DarkGray))
831            .alignment(Alignment::Center);
832        frame.render_widget(help, help_chunk);
833    }
834
835    /// Render base URL input screen (openai-compatible only)
836    fn render_base_url_input(&mut self, frame: &mut Frame) {
837        let chunks = Layout::default()
838            .direction(Direction::Vertical)
839            .margin(2)
840            .constraints([
841                Constraint::Length(3),
842                Constraint::Length(3),
843                Constraint::Min(0),
844                Constraint::Length(3),
845            ])
846            .split(frame.area());
847
848        let title = Paragraph::new("Configure OpenAI-Compatible Endpoint")
849            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
850            .alignment(Alignment::Center)
851            .block(Block::default().borders(Borders::ALL));
852        frame.render_widget(title, chunks[0]);
853
854        let input_text = if self.base_url_cursor < self.base_url.len() {
855            format!(
856                "{}█{}",
857                &self.base_url[..self.base_url_cursor],
858                &self.base_url[self.base_url_cursor..]
859            )
860        } else {
861            format!("{}█", self.base_url)
862        };
863
864        let input = Paragraph::new(input_text)
865            .style(Style::default().fg(Color::Yellow))
866            .block(
867                Block::default()
868                    .borders(Borders::ALL)
869                    .title("Base URL (e.g. http://localhost:1234/v1)"),
870            );
871        frame.render_widget(input, chunks[1]);
872
873        let message_widget = if let Some(ref error) = self.error_message {
874            Paragraph::new(error.as_str())
875                .style(Style::default().fg(Color::Red))
876                .alignment(Alignment::Center)
877        } else if let Some(ref existing) = self.existing_base_url {
878            Paragraph::new(format!(
879                "Current base URL: {}\n\
880                Examples: LMStudio http://localhost:1234/v1 · Ollama http://localhost:11434/v1\n\
881                Press Enter to continue.",
882                existing
883            ))
884            .style(Style::default().fg(Color::Yellow))
885            .alignment(Alignment::Center)
886            .wrap(Wrap { trim: true })
887        } else {
888            Paragraph::new(
889                "Enter the base URL of your OpenAI-compatible endpoint.\n\
890                Examples: LMStudio http://localhost:1234/v1 · Ollama http://localhost:11434/v1\n\
891                The /chat/completions path will be appended automatically.",
892            )
893            .style(Style::default().fg(Color::Green))
894            .alignment(Alignment::Center)
895            .wrap(Wrap { trim: true })
896        };
897        frame.render_widget(message_widget, chunks[2]);
898
899        let help = Paragraph::new("Enter to continue, Esc to go back, Ctrl+C to quit")
900            .style(Style::default().fg(Color::DarkGray))
901            .alignment(Alignment::Center);
902        frame.render_widget(help, chunks[3]);
903    }
904
905    /// Render free-text model input screen (openai-compatible only)
906    fn render_model_text_input(&mut self, frame: &mut Frame) {
907        let chunks = Layout::default()
908            .direction(Direction::Vertical)
909            .margin(2)
910            .constraints([
911                Constraint::Length(3),
912                Constraint::Length(3),
913                Constraint::Min(0),
914                Constraint::Length(3),
915            ])
916            .split(frame.area());
917
918        let title = Paragraph::new("Specify Model Name")
919            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
920            .alignment(Alignment::Center)
921            .block(Block::default().borders(Borders::ALL));
922        frame.render_widget(title, chunks[0]);
923
924        let input_text = if self.model_text_cursor < self.model_text.len() {
925            format!(
926                "{}█{}",
927                &self.model_text[..self.model_text_cursor],
928                &self.model_text[self.model_text_cursor..]
929            )
930        } else {
931            format!("{}█", self.model_text)
932        };
933
934        let input = Paragraph::new(input_text)
935            .style(Style::default().fg(Color::Yellow))
936            .block(
937                Block::default()
938                    .borders(Borders::ALL)
939                    .title("Model name (as it appears on your endpoint)"),
940            );
941        frame.render_widget(input, chunks[1]);
942
943        let message_widget = if let Some(ref error) = self.error_message {
944            Paragraph::new(error.as_str())
945                .style(Style::default().fg(Color::Red))
946                .alignment(Alignment::Center)
947        } else if let Some(ref existing) = self.existing_compatible_model {
948            Paragraph::new(format!(
949                "Current model: {}\n\
950                Type the exact model identifier loaded on your server.",
951                existing
952            ))
953            .style(Style::default().fg(Color::Yellow))
954            .alignment(Alignment::Center)
955            .wrap(Wrap { trim: true })
956        } else {
957            Paragraph::new(
958                "Enter the model name your server hosts.\n\
959                Examples: qwen2.5-coder-32b-instruct, llama-3.1-8b-instruct, mistral-7b",
960            )
961            .style(Style::default().fg(Color::Green))
962            .alignment(Alignment::Center)
963            .wrap(Wrap { trim: true })
964        };
965        frame.render_widget(message_widget, chunks[2]);
966
967        let help = Paragraph::new("Enter to test connection, Esc to go back, Ctrl+C to quit")
968            .style(Style::default().fg(Color::DarkGray))
969            .alignment(Alignment::Center);
970        frame.render_widget(help, chunks[3]);
971    }
972
973    /// Render fetching models loading screen
974    fn render_fetching_models(&mut self, frame: &mut Frame) {
975        let chunks = Layout::default()
976            .direction(Direction::Vertical)
977            .margin(2)
978            .constraints([
979                Constraint::Length(3),
980                Constraint::Min(0),
981            ])
982            .split(frame.area());
983
984        // Title
985        let title = Paragraph::new("Fetching Available Models...")
986            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
987            .alignment(Alignment::Center)
988            .block(Block::default().borders(Borders::ALL));
989        frame.render_widget(title, chunks[0]);
990
991        // Loading message — name the actual provider being queried
992        let provider_label = match self.selected_provider() {
993            "openrouter" => "OpenRouter",
994            "openai" => "OpenAI",
995            "anthropic" => "Anthropic",
996            other => other,
997        };
998        let body = format!("Loading models from {}...\n\nPlease wait...", provider_label);
999        let message = Paragraph::new(body)
1000            .style(Style::default().fg(Color::Yellow))
1001            .alignment(Alignment::Center)
1002            .wrap(Wrap { trim: true });
1003        frame.render_widget(message, chunks[1]);
1004    }
1005
1006    /// Render sort strategy selection screen (OpenRouter only)
1007    fn render_sort_strategy_selection(&mut self, frame: &mut Frame) {
1008        let chunks = Layout::default()
1009            .direction(Direction::Vertical)
1010            .margin(2)
1011            .constraints([
1012                Constraint::Length(3),
1013                Constraint::Min(0),
1014                Constraint::Length(3),
1015            ])
1016            .split(frame.area());
1017
1018        // Title
1019        let title = Paragraph::new("Select Provider Sort Strategy (OpenRouter)")
1020            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
1021            .alignment(Alignment::Center)
1022            .block(Block::default().borders(Borders::ALL));
1023        frame.render_widget(title, chunks[0]);
1024
1025        // Sort strategy list
1026        let strategy_items: Vec<ListItem> = OPENROUTER_SORT_STRATEGIES
1027            .iter()
1028            .enumerate()
1029            .map(|(idx, (name, description))| {
1030                let display = if idx == 0 {
1031                    format!("{} - {} (recommended)", name, description)
1032                } else {
1033                    format!("{} - {}", name, description)
1034                };
1035
1036                ListItem::new(display)
1037            })
1038            .collect();
1039
1040        let list = List::new(strategy_items)
1041            .block(
1042                Block::default()
1043                    .borders(Borders::ALL)
1044                    .title("Select Sort Strategy (↑/↓ to navigate, Enter to select, Esc to go back)"),
1045            )
1046            .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
1047            .highlight_symbol("> ");
1048
1049        let mut list_state = ListState::default().with_selected(Some(self.selected_sort_idx));
1050        frame.render_stateful_widget(list, chunks[1], &mut list_state);
1051
1052        // Help text
1053        let help = Paragraph::new("Controls how OpenRouter selects the upstream provider for your chosen model")
1054            .style(Style::default().fg(Color::DarkGray))
1055            .alignment(Alignment::Center);
1056        frame.render_widget(help, chunks[2]);
1057    }
1058
1059    /// Render connectivity test screen
1060    fn render_connectivity_test(&mut self, frame: &mut Frame) {
1061        let chunks = Layout::default()
1062            .direction(Direction::Vertical)
1063            .margin(2)
1064            .constraints([
1065                Constraint::Length(3),
1066                Constraint::Min(0),
1067            ])
1068            .split(frame.area());
1069
1070        // Title
1071        let title = Paragraph::new("Testing Connection...")
1072            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
1073            .alignment(Alignment::Center)
1074            .block(Block::default().borders(Borders::ALL));
1075        frame.render_widget(title, chunks[0]);
1076
1077        // Loading message
1078        let message = Paragraph::new(format!(
1079            "Testing connection to {}...\n\nPlease wait...",
1080            self.selected_provider()
1081        ))
1082        .style(Style::default().fg(Color::Yellow))
1083        .alignment(Alignment::Center)
1084        .wrap(Wrap { trim: true });
1085        frame.render_widget(message, chunks[1]);
1086    }
1087
1088    /// Render result screen
1089    fn render_result(&mut self, frame: &mut Frame, success: bool, message: &str) {
1090        let chunks = Layout::default()
1091            .direction(Direction::Vertical)
1092            .margin(2)
1093            .constraints([
1094                Constraint::Length(3),
1095                Constraint::Min(0),
1096                Constraint::Length(3),
1097            ])
1098            .split(frame.area());
1099
1100        // Title
1101        let title = if success {
1102            Paragraph::new("Configuration Successful!")
1103                .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
1104        } else {
1105            Paragraph::new("Configuration Failed")
1106                .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
1107        };
1108        let title = title.alignment(Alignment::Center).block(Block::default().borders(Borders::ALL));
1109        frame.render_widget(title, chunks[0]);
1110
1111        // Message
1112        let message_widget = Paragraph::new(message)
1113            .style(if success {
1114                Style::default().fg(Color::Green)
1115            } else {
1116                Style::default().fg(Color::Red)
1117            })
1118            .alignment(Alignment::Center)
1119            .wrap(Wrap { trim: true });
1120        frame.render_widget(message_widget, chunks[1]);
1121
1122        // Help text
1123        let help = Paragraph::new(if success {
1124            "Press Enter, q, or Ctrl+C to exit"
1125        } else {
1126            "Press Enter, q, or Ctrl+C to exit (configuration not saved)"
1127        })
1128        .style(Style::default().fg(Color::DarkGray))
1129        .alignment(Alignment::Center);
1130        frame.render_widget(help, chunks[2]);
1131    }
1132}
1133
1134/// Setup terminal for TUI
1135fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
1136    enable_raw_mode().context("Failed to enable raw mode")?;
1137    let mut stdout = io::stdout();
1138    execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
1139    let backend = CrosstermBackend::new(stdout);
1140    Terminal::new(backend).context("Failed to create terminal")
1141}
1142
1143/// Restore terminal to normal mode
1144fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
1145    disable_raw_mode().context("Failed to disable raw mode")?;
1146    execute!(terminal.backend_mut(), LeaveAlternateScreen)
1147        .context("Failed to leave alternate screen")?;
1148    terminal.show_cursor().context("Failed to show cursor")?;
1149    Ok(())
1150}
1151
1152/// Run the configuration wizard
1153pub fn run_configure_wizard() -> Result<()> {
1154    use std::io::IsTerminal;
1155    if !std::io::stdin().is_terminal() {
1156        anyhow::bail!(
1157            "The configuration wizard requires an interactive terminal.\n\
1158             \n\
1159             Run `rfx llm config` in an interactive terminal session, or configure\n\
1160             via environment variables instead:\n\
1161             \n\
1162             For OpenAI:     export OPENAI_API_KEY=sk-...\n\
1163             For Anthropic:  export ANTHROPIC_API_KEY=sk-ant-...\n\
1164             For OpenRouter: export OPENROUTER_API_KEY=sk-or-..."
1165        );
1166    }
1167    let mut terminal = setup_terminal()?;
1168    let mut wizard = ConfigWizard::new();
1169
1170    let result = run_wizard_loop(&mut terminal, &mut wizard);
1171
1172    // Always restore terminal
1173    restore_terminal(&mut terminal)?;
1174
1175    result
1176}
1177
1178/// Main wizard event loop
1179fn run_wizard_loop(
1180    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
1181    wizard: &mut ConfigWizard,
1182) -> Result<()> {
1183    loop {
1184        // Render current screen
1185        terminal.draw(|frame| wizard.render(frame))?;
1186
1187        // Dispatch /v1/models fetch by provider. OpenRouter dead-ends on error
1188        // because pricing data is critical to its UX; OpenAI and Anthropic fall
1189        // back to a small offline list so the wizard remains usable.
1190        if wizard.screen == WizardScreen::FetchingModels {
1191            let provider = wizard.selected_provider().to_string();
1192            match provider.as_str() {
1193                "openrouter" => match fetch_openrouter_models(&wizard.api_key) {
1194                    Ok(models) => {
1195                        wizard.fetched_models = models;
1196                        wizard.selected_model_idx = 0;
1197                        wizard.model_filter.clear();
1198                        wizard.error_message = None;
1199                        wizard.screen = WizardScreen::ModelSelection;
1200                    }
1201                    Err(e) => {
1202                        wizard.screen = WizardScreen::Result {
1203                            success: false,
1204                            message: format!(
1205                                "Failed to fetch models from OpenRouter: {}\n\n\
1206                                Please check your API key and try again.",
1207                                e
1208                            ),
1209                        };
1210                    }
1211                },
1212                "openai" => match fetch_openai_models_blocking(&wizard.api_key) {
1213                    Ok(ids) => {
1214                        wizard.fetched_dynamic_models = ids;
1215                        wizard.selected_model_idx = 0;
1216                        wizard.model_filter.clear();
1217                        wizard.error_message = None;
1218                        wizard.screen = WizardScreen::ModelSelection;
1219                    }
1220                    Err(e) => {
1221                        log::warn!("OpenAI /v1/models fetch failed, using fallback list: {}", e);
1222                        wizard.fetched_dynamic_models =
1223                            OPENAI_FALLBACK_MODELS.iter().map(|s| s.to_string()).collect();
1224                        wizard.selected_model_idx = 0;
1225                        wizard.model_filter.clear();
1226                        wizard.error_message = Some(
1227                            "Could not reach api.openai.com — showing recent models. \
1228                            Some newer models may be missing."
1229                                .to_string(),
1230                        );
1231                        wizard.screen = WizardScreen::ModelSelection;
1232                    }
1233                },
1234                "anthropic" => match fetch_anthropic_models_blocking(&wizard.api_key) {
1235                    Ok(ids) => {
1236                        wizard.fetched_dynamic_models = ids;
1237                        wizard.selected_model_idx = 0;
1238                        wizard.model_filter.clear();
1239                        wizard.error_message = None;
1240                        wizard.screen = WizardScreen::ModelSelection;
1241                    }
1242                    Err(e) => {
1243                        log::warn!("Anthropic /v1/models fetch failed, using fallback list: {}", e);
1244                        wizard.fetched_dynamic_models = ANTHROPIC_FALLBACK_MODELS
1245                            .iter()
1246                            .map(|s| s.to_string())
1247                            .collect();
1248                        wizard.selected_model_idx = 0;
1249                        wizard.model_filter.clear();
1250                        wizard.error_message = Some(
1251                            "Could not reach api.anthropic.com — showing recent models. \
1252                            Some newer models may be missing."
1253                                .to_string(),
1254                        );
1255                        wizard.screen = WizardScreen::ModelSelection;
1256                    }
1257                },
1258                _ => {
1259                    // Unexpected provider in FetchingModels; fall through.
1260                    wizard.screen = WizardScreen::ModelSelection;
1261                }
1262            }
1263            continue;
1264        }
1265
1266        // Handle connectivity test asynchronously
1267        if wizard.screen == WizardScreen::ConnectivityTest {
1268            let provider = wizard.selected_provider().to_string();
1269            let is_compatible = provider == "openai-compatible";
1270
1271            // openai-compatible uses free-text model input; others use list selection
1272            let selected_model = if is_compatible {
1273                wizard.model_text.clone()
1274            } else {
1275                wizard.selected_model()
1276            };
1277
1278            // Build provider options. openai-compatible needs base_url here, or
1279            // the factory will bail and the connectivity test would never reach
1280            // the network. OpenRouter passes sort via save (not test).
1281            let options = if is_compatible {
1282                let mut opts = HashMap::new();
1283                opts.insert("base_url".to_string(), wizard.base_url.clone());
1284                Some(opts)
1285            } else {
1286                None
1287            };
1288
1289            let result = test_connectivity(&provider, &wizard.api_key, &selected_model, options);
1290            match result {
1291                Ok(_) => {
1292                    // Save configuration
1293                    let sort = if provider == "openrouter" {
1294                        Some(wizard.selected_sort())
1295                    } else {
1296                        None
1297                    };
1298                    let base_url = if is_compatible {
1299                        Some(wizard.base_url.as_str())
1300                    } else {
1301                        None
1302                    };
1303                    if let Err(e) = save_user_config(
1304                        &provider,
1305                        &wizard.api_key,
1306                        &selected_model,
1307                        sort,
1308                        base_url,
1309                    ) {
1310                        wizard.screen = WizardScreen::Result {
1311                            success: false,
1312                            message: format!("Failed to save configuration: {}", e),
1313                        };
1314                    } else {
1315                        wizard.screen = WizardScreen::Result {
1316                            success: true,
1317                            message: format!(
1318                                "Configuration saved successfully!\n\n\
1319                                Provider: {}\n\
1320                                Config file: ~/.reflex/config.toml\n\n\
1321                                You can now use 'rfx ask' to query your codebase.",
1322                                provider
1323                            ),
1324                        };
1325                    }
1326                }
1327                Err(e) => {
1328                    wizard.screen = WizardScreen::Result {
1329                        success: false,
1330                        message: format!(
1331                            "Connectivity test failed: {}\n\n\
1332                            Please check your endpoint, model, and credentials and try again.",
1333                            e
1334                        ),
1335                    };
1336                }
1337            }
1338            continue;
1339        }
1340
1341        // Handle keyboard input
1342        if event::poll(std::time::Duration::from_millis(100))? {
1343            if let Event::Key(key) = event::read()? {
1344                let should_exit = wizard.handle_key(key)?;
1345                if should_exit {
1346                    break;
1347                }
1348            }
1349        }
1350    }
1351
1352    Ok(())
1353}
1354
1355/// Test connectivity to the selected provider
1356fn test_connectivity(
1357    provider_name: &str,
1358    api_key: &str,
1359    model: &str,
1360    options: Option<HashMap<String, String>>,
1361) -> Result<()> {
1362    // Create a tokio runtime for async operations
1363    let runtime = tokio::runtime::Runtime::new()
1364        .context("Failed to create async runtime")?;
1365
1366    runtime.block_on(async {
1367        // openai-compatible needs the model passed through; other providers
1368        // can fall back to their built-in defaults if no model is given.
1369        let model_arg = if model.is_empty() {
1370            None
1371        } else {
1372            Some(model.to_string())
1373        };
1374
1375        // Create provider instance
1376        let provider = crate::semantic::providers::create_provider(
1377            provider_name,
1378            api_key.to_string(),
1379            model_arg,
1380            options,
1381            crate::semantic::config::SemanticConfig::default().timeout_seconds,
1382        )?;
1383
1384        // Try to make a simple API call to test connectivity
1385        // Note: Must contain "json" for OpenAI structured output requirement
1386        let test_prompt = "Please respond with valid JSON: {\"status\": \"ok\"}";
1387
1388        // Call complete method (json_mode: true for test). Some local servers
1389        // do not honor response_format, but the call should still complete.
1390        provider.complete(test_prompt, true).await?;
1391
1392        Ok::<(), anyhow::Error>(())
1393    })?;
1394
1395    Ok(())
1396}
1397
1398/// Fetch models from OpenRouter API (blocking wrapper)
1399fn fetch_openrouter_models(api_key: &str) -> Result<Vec<OpenRouterModel>> {
1400    let runtime = tokio::runtime::Runtime::new()
1401        .context("Failed to create async runtime")?;
1402    runtime.block_on(async {
1403        crate::semantic::providers::openrouter::fetch_models(api_key).await
1404    })
1405}
1406
1407/// Fetch chat models from OpenAI's /v1/models (blocking wrapper)
1408fn fetch_openai_models_blocking(api_key: &str) -> Result<Vec<String>> {
1409    let runtime = tokio::runtime::Runtime::new()
1410        .context("Failed to create async runtime")?;
1411    runtime.block_on(async {
1412        crate::semantic::providers::openai::fetch_models(api_key).await
1413    })
1414}
1415
1416/// Fetch chat models from Anthropic's /v1/models (blocking wrapper)
1417fn fetch_anthropic_models_blocking(api_key: &str) -> Result<Vec<String>> {
1418    let runtime = tokio::runtime::Runtime::new()
1419        .context("Failed to create async runtime")?;
1420    runtime.block_on(async {
1421        crate::semantic::providers::anthropic::fetch_models(api_key).await
1422    })
1423}
1424
1425/// Save user configuration to ~/.reflex/config.toml
1426fn save_user_config(
1427    provider: &str,
1428    api_key: &str,
1429    model: &str,
1430    sort: Option<&str>,
1431    base_url: Option<&str>,
1432) -> Result<()> {
1433    use serde::{Deserialize, Serialize};
1434    use std::fs;
1435
1436    #[derive(Debug, Serialize, Deserialize)]
1437    struct UserConfig {
1438        #[serde(default)]
1439        semantic: SemanticSection,
1440        #[serde(default)]
1441        credentials: HashMap<String, String>,
1442    }
1443
1444    #[derive(Debug, Serialize, Deserialize)]
1445    struct SemanticSection {
1446        provider: String,
1447    }
1448
1449    impl Default for SemanticSection {
1450        fn default() -> Self {
1451            Self {
1452                provider: "openai".to_string(),
1453            }
1454        }
1455    }
1456
1457    let home = dirs::home_dir()
1458        .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
1459
1460    let config_dir = home.join(".reflex");
1461    fs::create_dir_all(&config_dir)
1462        .context("Failed to create ~/.reflex directory")?;
1463
1464    let config_path = config_dir.join("config.toml");
1465
1466    // Load existing config if it exists
1467    let mut config = if config_path.exists() {
1468        let config_str = fs::read_to_string(&config_path)
1469            .context("Failed to read existing config file")?;
1470        toml::from_str::<UserConfig>(&config_str)
1471            .unwrap_or_else(|_| UserConfig {
1472                semantic: SemanticSection::default(),
1473                credentials: HashMap::new(),
1474            })
1475    } else {
1476        UserConfig {
1477            semantic: SemanticSection::default(),
1478            credentials: HashMap::new(),
1479        }
1480    };
1481
1482    // The [semantic] provider value stays in its user-facing kebab-case form
1483    // (e.g. "openai-compatible"), but credential field names use underscores
1484    // to match the serde fields on the Credentials struct.
1485    config.semantic.provider = provider.to_string();
1486    let cred_prefix = provider.replace('-', "_");
1487
1488    // Update the specific provider's key and model in credentials
1489    let key_name = format!("{}_api_key", cred_prefix);
1490    let model_name = format!("{}_model", cred_prefix);
1491    config.credentials.insert(key_name, api_key.to_string());
1492    config.credentials.insert(model_name, model.to_string());
1493
1494    // Save sort strategy for OpenRouter
1495    if let Some(sort_value) = sort {
1496        config.credentials.insert("openrouter_sort".to_string(), sort_value.to_string());
1497    }
1498
1499    // Save base URL for openai-compatible
1500    if let Some(url) = base_url {
1501        config
1502            .credentials
1503            .insert(format!("{}_base_url", cred_prefix), url.to_string());
1504    }
1505
1506    // Serialize to TOML
1507    let toml_content = toml::to_string_pretty(&config)
1508        .context("Failed to serialize config to TOML")?;
1509
1510    // Prepend comment header
1511    let final_content = format!(
1512        "# Reflex User Configuration\n\
1513         # This file stores your AI provider API keys\n\
1514         # Location: ~/.reflex/config.toml\n\
1515         \n\
1516         {}",
1517        toml_content
1518    );
1519
1520    fs::write(&config_path, final_content)
1521        .context("Failed to write configuration file")?;
1522
1523    log::info!("Configuration saved to {:?}", config_path);
1524
1525    Ok(())
1526}