diff --git a/src/backends/anthropic.rs b/src/backends/anthropic.rs index cdb9b5b..a6a70e8 100644 --- a/src/backends/anthropic.rs +++ b/src/backends/anthropic.rs @@ -35,6 +35,8 @@ use serde_json::Value; pub struct AnthropicConfig { /// API key for authentication with Anthropic. pub api_key: String, + /// Base URL for API requests. + pub base_url: String, /// Model identifier (e.g., "claude-3-sonnet-20240229"). pub model: String, /// Maximum tokens to generate in responses. @@ -350,23 +352,25 @@ impl Anthropic { fn convert_messages_to_anthropic<'a>(messages: &'a [ChatMessage]) -> Vec> { messages .iter() - .map(|m| AnthropicMessage { - role: match m.role { - ChatRole::User => "user", - ChatRole::Assistant => "assistant", - }, - content: match &m.message_type { - MessageType::Text => vec![MessageContent { - message_type: Some("text"), - text: Some(&m.content), - image_url: None, - source: None, - tool_use_id: None, - tool_input: None, - tool_name: None, - tool_result_id: None, - tool_output: None, - }], + .filter_map(|m| { + let content = match &m.message_type { + MessageType::Text => { + // Skip empty text messages - Anthropic API rejects empty text content blocks + if m.content.trim().is_empty() { + return None; + } + vec![MessageContent { + message_type: Some("text"), + text: Some(&m.content), + image_url: None, + source: None, + tool_use_id: None, + tool_input: None, + tool_name: None, + tool_result_id: None, + tool_output: None, + }] + } MessageType::Pdf(raw_bytes) => { vec![MessageContent { message_type: Some("document"), @@ -444,7 +448,19 @@ impl Anthropic { tool_output: Some(r.function.arguments.clone()), }) .collect(), - }, + }; + + Some(AnthropicMessage { + role: match m.role { + ChatRole::User => "user", + ChatRole::Assistant => match &m.message_type { + // In Anthropic API spec, tool results must be sent as USER messages instead of ASSISTANT + MessageType::ToolResult(_) => "user", + _ => "assistant", + }, + }, + content, + }) }) .collect() } @@ -509,6 +525,7 @@ impl Anthropic { /// # Arguments /// /// * `api_key` - Anthropic API key for authentication + /// * `base_url` - Base URL for API requests (defaults to "https://api.anthropic.com/v1") /// * `model` - Model identifier (defaults to "claude-3-sonnet-20240229") /// * `max_tokens` - Maximum tokens in response (defaults to 300) /// * `temperature` - Sampling temperature (defaults to 0.7) @@ -518,6 +535,7 @@ impl Anthropic { #[allow(clippy::too_many_arguments)] pub fn new( api_key: impl Into, + base_url: Option, model: Option, max_tokens: Option, temperature: Option, @@ -538,6 +556,7 @@ impl Anthropic { Self::with_client( builder.build().expect("Failed to build reqwest Client"), api_key, + base_url, model, max_tokens, temperature, @@ -562,6 +581,7 @@ impl Anthropic { /// /// * `client` - A pre-configured `reqwest::Client` for HTTP requests /// * `api_key` - Anthropic API key for authentication + /// * `base_url` - Base URL for API requests (defaults to "https://api.anthropic.com/v1") /// * `model` - Model identifier (defaults to "claude-3-sonnet-20240229") /// * `max_tokens` - Maximum tokens in response (defaults to 300) /// * `temperature` - Sampling temperature (defaults to 0.7) @@ -572,6 +592,7 @@ impl Anthropic { pub fn with_client( client: Client, api_key: impl Into, + base_url: Option, model: Option, max_tokens: Option, temperature: Option, @@ -587,6 +608,7 @@ impl Anthropic { Self { config: Arc::new(AnthropicConfig { api_key: api_key.into(), + base_url: base_url.unwrap_or_else(|| "https://api.anthropic.com/v1".to_string()), model: model.unwrap_or_else(|| "claude-3-sonnet-20240229".to_string()), max_tokens: max_tokens.unwrap_or(300), temperature: temperature.unwrap_or(0.7), @@ -609,6 +631,10 @@ impl Anthropic { &self.config.api_key } + pub fn base_url(&self) -> &str { + &self.config.base_url + } + pub fn model(&self) -> &str { &self.config.model } @@ -714,9 +740,10 @@ impl ChatProvider for Anthropic { thinking, }; + let url = format!("{}/messages", self.config.base_url.trim_end_matches('/')); let mut request = self .client - .post("https://api.anthropic.com/v1/messages") + .post(&url) .header("x-api-key", &self.config.api_key) .header("Content-Type", "application/json") .header("anthropic-version", "2023-06-01") @@ -844,9 +871,10 @@ impl ChatProvider for Anthropic { thinking: None, }; + let url = format!("{}/messages", self.config.base_url); let mut request = self .client - .post("https://api.anthropic.com/v1/messages") + .post(&url) .header("x-api-key", &self.config.api_key) .header("Content-Type", "application/json") .header("anthropic-version", "2023-06-01") @@ -914,9 +942,10 @@ impl ChatProvider for Anthropic { thinking: None, // Thinking not supported with streaming tools }; + let url = format!("{}/messages", self.config.base_url); let mut request = self .client - .post("https://api.anthropic.com/v1/messages") + .post(&url) .header("x-api-key", &self.config.api_key) .header("Content-Type", "application/json") .header("anthropic-version", "2023-06-01") @@ -1087,9 +1116,10 @@ impl ModelsProvider for Anthropic { &self, _request: Option<&ModelListRequest>, ) -> Result, LLMError> { + let url = format!("{}/models", self.config.base_url); let resp = self .client - .get("https://api.anthropic.com/v1/models") + .get(&url) .header("x-api-key", &self.config.api_key) .header("Content-Type", "application/json") .header("anthropic-version", "2023-06-01") diff --git a/src/bin/llm-cli/conversation/convert.rs b/src/bin/llm-cli/conversation/convert.rs index 5d50ba7..6037235 100644 --- a/src/bin/llm-cli/conversation/convert.rs +++ b/src/bin/llm-cli/conversation/convert.rs @@ -53,6 +53,13 @@ pub fn to_chat_messages(messages: &[ConversationMessage]) -> Vec { i += 1; } MessageKind::Text(content) => { + // Skip empty messages (placeholders that haven't been filled yet) + // These are typically assistant messages with Streaming or Pending state + if content.trim().is_empty() { + i += 1; + continue; + } + match message.role { MessageRole::User => { result.push(ChatMessage::user().content(content).build()); diff --git a/src/bin/llm-cli/runtime/controller/input/mod.rs b/src/bin/llm-cli/runtime/controller/input/mod.rs index 4a29cb0..72ff9a6 100644 --- a/src/bin/llm-cli/runtime/controller/input/mod.rs +++ b/src/bin/llm-cli/runtime/controller/input/mod.rs @@ -39,9 +39,20 @@ pub fn handle_tick(controller: &mut AppController) -> bool { fn handle_paste_event(controller: &mut AppController, text: String) -> bool { if let OverlayState::Onboarding(state) = &mut controller.state.overlay { - state.api_key.push_str(&text); - controller.state.paste_detector.record_paste(Instant::now()); - return true; + use crate::runtime::OnboardingStep; + match state.step { + OnboardingStep::ApiKey => { + state.api_key.push_str(&text); + controller.state.paste_detector.record_paste(Instant::now()); + return true; + } + OnboardingStep::BaseUrl => { + state.base_url.push_str(&text); + controller.state.paste_detector.record_paste(Instant::now()); + return true; + } + _ => {} + } } helpers::handle_paste(controller, text) } diff --git a/src/bin/llm-cli/runtime/controller/input/overlay_keys/handlers/onboarding.rs b/src/bin/llm-cli/runtime/controller/input/overlay_keys/handlers/onboarding.rs index d61682e..cc671b9 100644 --- a/src/bin/llm-cli/runtime/controller/input/overlay_keys/handlers/onboarding.rs +++ b/src/bin/llm-cli/runtime/controller/input/overlay_keys/handlers/onboarding.rs @@ -10,6 +10,7 @@ pub(super) fn handle_onboarding(state: &mut OnboardingState, key: KeyEvent) -> O OnboardingStep::Welcome => handle_onboarding_welcome(state, key), OnboardingStep::Provider => handle_onboarding_provider(state, key), OnboardingStep::ApiKey => handle_onboarding_key(state, key), + OnboardingStep::BaseUrl => handle_onboarding_base_url(state, key), OnboardingStep::Preferences => handle_onboarding_preferences(state, key), OnboardingStep::Confirm => handle_onboarding_confirm(state, key), } @@ -78,6 +79,36 @@ fn handle_onboarding_key(state: &mut OnboardingState, key: KeyEvent) -> OverlayR } } +fn handle_onboarding_base_url(state: &mut OnboardingState, key: KeyEvent) -> OverlayResult { + match key.code { + KeyCode::Esc => { + state.prev_step(); + OverlayResult::action(OverlayAction::Handled) + } + KeyCode::Backspace => { + state.error = None; + state.base_url.pop(); + OverlayResult::action(OverlayAction::Handled) + } + KeyCode::Char(ch) => { + state.error = None; + state.base_url.push(ch); + OverlayResult::action(OverlayAction::Handled) + } + KeyCode::Enter => { + // If empty and a default exists, use the default + if state.base_url.trim().is_empty() { + if let Some(default_url) = state.default_base_url() { + state.base_url = default_url.to_string(); + } + } + state.next_step(); + OverlayResult::action(OverlayAction::Handled) + } + _ => OverlayResult::action(OverlayAction::None), + } +} + fn handle_onboarding_preferences(state: &mut OnboardingState, key: KeyEvent) -> OverlayResult { match key.code { KeyCode::Esc => { diff --git a/src/bin/llm-cli/runtime/controller/onboarding.rs b/src/bin/llm-cli/runtime/controller/onboarding.rs index 87a2171..a1f6725 100644 --- a/src/bin/llm-cli/runtime/controller/onboarding.rs +++ b/src/bin/llm-cli/runtime/controller/onboarding.rs @@ -61,6 +61,23 @@ fn apply_onboarding_config( controller.state.config.default_provider = Some(provider.id.as_str().to_string()); controller.state.config.ui.navigation_mode = state.mode; controller.state.config.ui.theme = state.theme.clone(); + + // Save provider config (backend and base_url) + let provider_config = controller + .state + .config + .providers + .entry(provider.id.as_str().to_string()) + .or_default(); + + // Always set the backend field to ensure it's correctly configured + provider_config.backend = Some(provider.backend.to_string()); + + // Save base_url if provided + if !state.base_url.trim().is_empty() { + provider_config.base_url = Some(state.base_url.trim().to_string()); + } + if let Err(err) = save_config(&controller.state.config, &controller.config_paths) { controller.set_status(AppStatus::Error(format!("save config: {err}"))); return Err(format!("save config: {err}")); diff --git a/src/bin/llm-cli/runtime/overlay/onboarding.rs b/src/bin/llm-cli/runtime/overlay/onboarding.rs index 8e58ef1..d365c91 100644 --- a/src/bin/llm-cli/runtime/overlay/onboarding.rs +++ b/src/bin/llm-cli/runtime/overlay/onboarding.rs @@ -8,6 +8,7 @@ pub enum OnboardingStep { Welcome, Provider, ApiKey, + BaseUrl, Preferences, Confirm, } @@ -25,6 +26,7 @@ pub struct OnboardingState { pub providers: Vec, pub selected: usize, pub api_key: String, + pub base_url: String, pub mode: NavigationMode, pub theme: String, pub error: Option, @@ -37,6 +39,7 @@ impl OnboardingState { providers, selected: 0, api_key: String::new(), + base_url: String::new(), mode, theme, error: None, @@ -47,7 +50,8 @@ impl OnboardingState { self.step = match self.step { OnboardingStep::Welcome => OnboardingStep::Provider, OnboardingStep::Provider => OnboardingStep::ApiKey, - OnboardingStep::ApiKey => OnboardingStep::Preferences, + OnboardingStep::ApiKey => OnboardingStep::BaseUrl, + OnboardingStep::BaseUrl => OnboardingStep::Preferences, OnboardingStep::Preferences => OnboardingStep::Confirm, OnboardingStep::Confirm => OnboardingStep::Confirm, }; @@ -59,7 +63,8 @@ impl OnboardingState { OnboardingStep::Welcome => OnboardingStep::Welcome, OnboardingStep::Provider => OnboardingStep::Welcome, OnboardingStep::ApiKey => OnboardingStep::Provider, - OnboardingStep::Preferences => OnboardingStep::ApiKey, + OnboardingStep::BaseUrl => OnboardingStep::ApiKey, + OnboardingStep::Preferences => OnboardingStep::BaseUrl, OnboardingStep::Confirm => OnboardingStep::Preferences, }; self.error = None; @@ -84,4 +89,13 @@ impl OnboardingState { pub fn set_error(&mut self, message: impl Into) { self.error = Some(message.into()); } + + pub fn default_base_url(&self) -> Option<&str> { + self.selected_provider().and_then(|provider| { + match provider.backend { + LLMBackend::Anthropic => Some("https://api.anthropic.com/v1"), + _ => None, + } + }) + } } diff --git a/src/bin/llm-cli/ui/overlay/onboarding.rs b/src/bin/llm-cli/ui/overlay/onboarding.rs index 614781f..081d52c 100644 --- a/src/bin/llm-cli/ui/overlay/onboarding.rs +++ b/src/bin/llm-cli/ui/overlay/onboarding.rs @@ -36,6 +36,7 @@ fn step_title(step: OnboardingStep) -> &'static str { OnboardingStep::Welcome => "Welcome", OnboardingStep::Provider => "Choose Provider", OnboardingStep::ApiKey => "API Key", + OnboardingStep::BaseUrl => "Base URL", OnboardingStep::Preferences => "Preferences", OnboardingStep::Confirm => "Confirm", } @@ -46,6 +47,7 @@ fn build_lines(state: &OnboardingState, theme: &Theme) -> Vec> { OnboardingStep::Welcome => welcome_lines(), OnboardingStep::Provider => provider_lines(state), OnboardingStep::ApiKey => api_key_lines(state), + OnboardingStep::BaseUrl => base_url_lines(state), OnboardingStep::Preferences => preference_lines(state), OnboardingStep::Confirm => confirm_lines(state), }; @@ -90,6 +92,31 @@ fn api_key_lines(state: &OnboardingState) -> Vec> { ] } +fn base_url_lines(state: &OnboardingState) -> Vec> { + let name = state + .selected_provider() + .map(|provider| provider.name.clone()) + .unwrap_or_else(|| "provider".to_string()); + + let default_info = if let Some(default_url) = state.default_base_url() { + format!("(default: {})", default_url) + } else { + "(optional)".to_string() + }; + + let display_url = if state.base_url.is_empty() { + "".to_string() + } else { + state.base_url.clone() + }; + + vec![ + Line::from(format!("Enter Base URL for {name} {}:", default_info)), + Line::from(display_url), + Line::from("Leave empty to use default. Press Enter to continue, Esc to go back."), + ] +} + fn preference_lines(state: &OnboardingState) -> Vec> { vec![ Line::from("Preferences:"), @@ -104,11 +131,19 @@ fn confirm_lines(state: &OnboardingState) -> Vec> { .selected_provider() .map(|p| p.id.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); - vec![ + + let mut lines = vec![ Line::from("Ready to go!"), Line::from(format!("Provider: {provider}")), - Line::from(format!("Mode: {:?}", state.mode)), - Line::from(format!("Theme: {}", state.theme)), - Line::from("Press Enter to finish."), - ] + ]; + + if !state.base_url.is_empty() { + lines.push(Line::from(format!("Base URL: {}", state.base_url))); + } + + lines.push(Line::from(format!("Mode: {:?}", state.mode))); + lines.push(Line::from(format!("Theme: {}", state.theme))); + lines.push(Line::from("Press Enter to finish.")); + + lines } diff --git a/src/builder/backend.rs b/src/builder/backend.rs index 3edc035..44bb4cc 100644 --- a/src/builder/backend.rs +++ b/src/builder/backend.rs @@ -1,3 +1,5 @@ +use std::fmt::Formatter; + use crate::error::LLMError; /// Supported LLM backend providers. @@ -46,3 +48,26 @@ impl std::str::FromStr for LLMBackend { } } } + +impl std::fmt::Display for LLMBackend { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let name = match self { + LLMBackend::OpenAI => "openai", + LLMBackend::Anthropic => "anthropic", + LLMBackend::DeepSeek => "deepseek", + LLMBackend::XAI => "xai", + LLMBackend::Google => "google", + LLMBackend::Groq => "groq", + LLMBackend::AzureOpenAI => "azure-openai", + LLMBackend::Cohere => "cohere", + LLMBackend::Mistral => "mistral", + LLMBackend::OpenRouter => "openrouter", + LLMBackend::HuggingFace => "huggingface", + LLMBackend::Ollama => "ollama", + LLMBackend::Phind => "phind", + LLMBackend::ElevenLabs => "elevenlabs", + LLMBackend::AwsBedrock => "aws-bedrock", + }; + write!(f, "{name}") + } +} diff --git a/src/builder/build/backends/anthropic.rs b/src/builder/build/backends/anthropic.rs index 483a596..5a0cbf0 100644 --- a/src/builder/build/backends/anthropic.rs +++ b/src/builder/build/backends/anthropic.rs @@ -22,6 +22,7 @@ pub(super) fn build_anthropic( let provider = crate::backends::anthropic::Anthropic::new( api_key, + state.base_url.take(), state.model.take(), state.max_tokens, state.temperature,