From 3f8fc65454364d79e97599320f16e2fcf8252388 Mon Sep 17 00:00:00 2001 From: herin <16355507@qq.com> Date: Sun, 8 Mar 2026 12:57:56 +0800 Subject: [PATCH] Add missing providers and custom URL configuration to init wizard This commit adds comprehensive provider support and custom URL configuration to the openfang init wizard, improving user experience for all supported LLM providers. Changes: - Add 13 missing providers (lemonade, minimax, zhipu, zhipu_coding, zai, zai_coding, moonshot, qianfan, volcengine, volcengine_coding, bedrock, codex, claude-code) - Add CustomUrl wizard step for OpenAI-compatible providers - Implement URL validation with http/https scheme check - Persist custom URLs to config.toml [provider_urls] section - Update step count from 7 to 8 - Fix default models: zhipu_coding uses glm-4.7, moonshot uses kimi-k2.5 Testing: - All 33 CLI unit tests pass - Zero clippy warnings - All formatting checks pass - Backward compatible (custom URL is optional) Co-Authored-By: Claude Sonnet 4.6 --- .../src/tui/screens/init_wizard.rs | 334 +++++++++++++++++- 1 file changed, 325 insertions(+), 9 deletions(-) diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 65bc19cd4..c6d5cfbdc 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -156,6 +156,114 @@ const PROVIDERS: &[ProviderInfo] = &[ needs_key: true, hint: "", }, + // Local providers + ProviderInfo { + name: "lemonade", + display: "Lemonade", + env_var: "LEMONADE_API_KEY", + default_model: "lemonade-7b", + needs_key: false, + hint: "local", + }, + // Chinese providers + ProviderInfo { + name: "minimax", + display: "MiniMax", + env_var: "MINIMAX_API_KEY", + default_model: "minimax-text-01", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "zhipu", + display: "Zhipu AI (GLM)", + env_var: "ZHIPU_API_KEY", + default_model: "glm-4-plus", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "zhipu_coding", + display: "Zhipu Coding (CodeGeeX)", + env_var: "ZHIPU_API_KEY", + default_model: "glm-4.7", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "zai", + display: "Z.AI", + env_var: "ZHIPU_API_KEY", + default_model: "zai-7b", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "zai_coding", + display: "Z.AI Coding", + env_var: "ZHIPU_API_KEY", + default_model: "zai-coding-7b", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "moonshot", + display: "Moonshot (Kimi)", + env_var: "MOONSHOT_API_KEY", + default_model: "kimi-k2.5", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "qianfan", + display: "Baidu Qianfan", + env_var: "QIANFAN_API_KEY", + default_model: "ERNIE-Bot-4", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "volcengine", + display: "Volcano Engine (Doubao)", + env_var: "VOLCENGINE_API_KEY", + default_model: "doubao-pro-256k", + needs_key: true, + hint: "", + }, + ProviderInfo { + name: "volcengine_coding", + display: "Volcano Engine Coding", + env_var: "VOLCENGINE_API_KEY", + default_model: "doubao-coding-32k", + needs_key: true, + hint: "", + }, + // Cloud providers + ProviderInfo { + name: "bedrock", + display: "AWS Bedrock", + env_var: "AWS_ACCESS_KEY_ID", + default_model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + needs_key: true, + hint: "", + }, + // CLI providers + ProviderInfo { + name: "codex", + display: "OpenAI Codex", + env_var: "OPENAI_API_KEY", + default_model: "codex/gpt-4.1", + needs_key: true, + hint: "via CODEX_HOME", + }, + ProviderInfo { + name: "claude-code", + display: "Claude Code", + env_var: "", + default_model: "claude-code/sonnet", + needs_key: false, + hint: "via CLAUDE_CODE", + }, ProviderInfo { name: "github-copilot", display: "GitHub Copilot", @@ -241,6 +349,7 @@ enum Step { Welcome, Migration, Provider, + CustomUrl, ApiKey, Model, Routing, @@ -312,6 +421,11 @@ struct State { key_test: KeyTestState, key_test_started: Option, + // Custom URL configuration + custom_url_input: String, + custom_url_error: Option, + custom_url_provider_selected: Option, + // Model selection model_input: String, model_catalog: ModelCatalog, @@ -355,6 +469,9 @@ impl State { api_key_from_env: false, key_test: KeyTestState::Idle, key_test_started: None, + custom_url_input: String::new(), + custom_url_error: None, + custom_url_provider_selected: None, model_input: String::new(), model_catalog: ModelCatalog::new(), model_entries: Vec::new(), @@ -404,13 +521,14 @@ impl State { fn step_label(&self) -> &'static str { match self.step { - Step::Welcome => "1 of 7", - Step::Migration => "2 of 7", - Step::Provider => "3 of 7", - Step::ApiKey => "4 of 7", - Step::Model => "5 of 7", - Step::Routing => "6 of 7", - Step::Complete => "7 of 7", + Step::Welcome => "1 of 8", + Step::Migration => "2 of 8", + Step::Provider => "3 of 8", + Step::CustomUrl => "4 of 8", + Step::ApiKey => "5 of 8", + Step::Model => "6 of 8", + Step::Routing => "7 of 8", + Step::Complete => "8 of 8", } } @@ -716,6 +834,80 @@ pub fn run() -> InitResult { state.selected_provider = Some(prov_idx); let p = &PROVIDERS[prov_idx]; + // Store provider name for custom URL step + state.custom_url_provider_selected = Some(p.name.to_string()); + state.custom_url_input.clear(); + state.custom_url_error = None; + + // Check if provider supports custom URL + let supports_custom_url = matches!( + p.name, + "openai" + | "openrouter" + | "together" + | "mistral" + | "fireworks" + | "perplexity" + | "cohere" + | "cerebras" + | "sambanova" + | "ai21" + | "huggingface" + | "replicate" + | "venice" + | "ollama" + | "lmstudio" + | "vllm" + | "minimax" + | "zhipu" + | "zhipu_coding" + | "zai" + | "zai_coding" + | "moonshot" + | "qianfan" + | "volcengine" + | "volcengine_coding" + | "lemonade" + | "bedrock" + ); + + if supports_custom_url { + state.step = Step::CustomUrl; + } else if !p.needs_key { + state.api_key_from_env = false; + state.load_models_for_provider(); + state.step = Step::Model; + } else if state.is_provider_detected(prov_idx) { + state.api_key_from_env = true; + state.load_models_for_provider(); + state.step = Step::Model; + } else { + state.api_key_from_env = false; + state.api_key_input.clear(); + state.key_test = KeyTestState::Idle; + state.step = Step::ApiKey; + } + } + } + _ => {} + }, + + Step::CustomUrl => match key.code { + KeyCode::Esc => { + state.custom_url_input.clear(); + state.custom_url_error = None; + state.step = Step::Provider; + } + KeyCode::Enter => { + // Validate URL + if state.custom_url_input.is_empty() { + // User pressed Enter without typing - skip custom URL + state.custom_url_input.clear(); + state.custom_url_error = None; + + let prov_idx = state.selected_provider.unwrap(); + let p = &PROVIDERS[prov_idx]; + if !p.needs_key { state.api_key_from_env = false; state.load_models_for_provider(); @@ -730,8 +922,46 @@ pub fn run() -> InitResult { state.key_test = KeyTestState::Idle; state.step = Step::ApiKey; } + } else { + // Validate URL format + match reqwest::Url::parse(&state.custom_url_input) { + Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => { + state.custom_url_error = None; + + // Proceed to API key step (or model if no key needed) + let prov_idx = state.selected_provider.unwrap(); + let p = &PROVIDERS[prov_idx]; + + if !p.needs_key { + state.load_models_for_provider(); + state.step = Step::Model; + } else if state.is_provider_detected(prov_idx) { + state.api_key_from_env = true; + state.load_models_for_provider(); + state.step = Step::Model; + } else { + state.api_key_from_env = false; + state.api_key_input.clear(); + state.key_test = KeyTestState::Idle; + state.step = Step::ApiKey; + } + } + _ => { + state.custom_url_error = Some( + "Invalid URL (use http:// or https://)".to_string(), + ); + } + } } } + KeyCode::Char(c) => { + state.custom_url_input.push(c); + state.custom_url_error = None; + } + KeyCode::Backspace => { + state.custom_url_input.pop(); + state.custom_url_error = None; + } _ => {} }, @@ -924,7 +1154,9 @@ fn handle_migration_key( let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") { PathBuf::from(h) } else { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang") + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openfang") }; let tx = migrate_tx.clone(); std::thread::spawn(move || { @@ -1083,6 +1315,19 @@ complex_threshold = 500 String::new() }; + // Custom URL section + let custom_url_section = if !state.custom_url_input.is_empty() { + format!( + r#" +[provider_urls] +{} = "{}" +"#, + p.name, state.custom_url_input + ) + } else { + String::new() + }; + let config_path = openfang_dir.join("config.toml"); let config = format!( r#"# OpenFang Agent OS configuration @@ -1094,12 +1339,13 @@ api_listen = "127.0.0.1:4200" provider = "{provider}" model = "{model}" api_key_env = "{env_var}" - +{custom_url_section} [memory] decay_rate = 0.05 {routing_section}"#, provider = p.name, env_var = p.env_var, + custom_url_section = custom_url_section, ); match std::fs::write(&config_path, &config) { @@ -1201,6 +1447,7 @@ fn draw(f: &mut Frame, area: Rect, state: &mut State) { Step::Welcome => draw_welcome(f, chunks[3]), Step::Migration => draw_migration(f, chunks[3], state), Step::Provider => draw_provider(f, chunks[3], state), + Step::CustomUrl => draw_custom_url(f, chunks[3], state), Step::ApiKey => draw_api_key(f, chunks[3], state), Step::Model => draw_model(f, chunks[3], state), Step::Routing => draw_routing(f, chunks[3], state), @@ -1725,6 +1972,75 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) { f.render_widget(hints, chunks[2]); } +fn draw_custom_url(f: &mut Frame, area: Rect, state: &mut State) { + let chunks = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let provider_name = state + .custom_url_provider_selected + .as_deref() + .unwrap_or("Unknown"); + + // Prompt + let prompt = Paragraph::new(Line::from(vec![ + Span::raw(format!(" Custom base URL for {}? ", provider_name)), + Span::styled("[Enter to skip]", theme::dim_style()), + ])); + f.render_widget(prompt, chunks[0]); + + // Explanation + let help = Paragraph::new(vec![ + Line::from(" Optional: Enter a custom base URL for this provider."), + Line::from(" Example: http://192.168.1.100:11434/v1"), + Line::from(vec![ + Span::raw(" Press "), + Span::styled("Enter", Style::default().fg(theme::ACCENT)), + Span::raw(" without typing to use the default URL."), + ]), + ]) + .style(theme::dim_style()); + f.render_widget(help, chunks[1]); + + // URL input field + let input_prompt = Line::from(vec![ + Span::styled(" URL: ", Style::default().fg(theme::ACCENT)), + Span::raw(&state.custom_url_input), + Span::styled(" ", Style::default().fg(theme::TEXT_PRIMARY)), // cursor + ]); + + let input_style = if state.custom_url_error.is_some() { + Style::default().fg(theme::RED) + } else { + Style::default().fg(theme::TEXT_PRIMARY) + }; + + let input_paragraph = Paragraph::new(input_prompt).style(input_style); + f.render_widget(input_paragraph, chunks[2]); + + // Error message (if any) + if let Some(ref error) = state.custom_url_error { + let error_msg = Paragraph::new(Line::from(vec![Span::styled( + format!(" \u{2717} {}", error), + Style::default().fg(theme::RED), + )])); + f.render_widget(error_msg, chunks[3]); + } + + // Keyboard hints + let hints = Paragraph::new(Line::from(vec![ + Span::styled(" [Enter] Confirm ", theme::hint_style()), + Span::styled("[Esc] Back", theme::hint_style()), + ])); + f.render_widget(hints, chunks[5]); +} + fn draw_api_key(f: &mut Frame, area: Rect, state: &mut State) { let p = match state.provider() { Some(p) => p,