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