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