Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 52 additions & 22 deletions src/backends/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -350,23 +352,25 @@ impl Anthropic {
fn convert_messages_to_anthropic<'a>(messages: &'a [ChatMessage]) -> Vec<AnthropicMessage<'a>> {
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"),
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)
Expand All @@ -518,6 +535,7 @@ impl Anthropic {
#[allow(clippy::too_many_arguments)]
pub fn new(
api_key: impl Into<String>,
base_url: Option<String>,
model: Option<String>,
max_tokens: Option<u32>,
temperature: Option<f32>,
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -572,6 +592,7 @@ impl Anthropic {
pub fn with_client(
client: Client,
api_key: impl Into<String>,
base_url: Option<String>,
model: Option<String>,
max_tokens: Option<u32>,
temperature: Option<f32>,
Expand All @@ -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),
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1087,9 +1116,10 @@ impl ModelsProvider for Anthropic {
&self,
_request: Option<&ModelListRequest>,
) -> Result<Box<dyn ModelListResponse>, 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")
Expand Down
7 changes: 7 additions & 0 deletions src/bin/llm-cli/conversation/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ pub fn to_chat_messages(messages: &[ConversationMessage]) -> Vec<ChatMessage> {
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());
Expand Down
17 changes: 14 additions & 3 deletions src/bin/llm-cli/runtime/controller/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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 => {
Expand Down
17 changes: 17 additions & 0 deletions src/bin/llm-cli/runtime/controller/onboarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"));
Expand Down
18 changes: 16 additions & 2 deletions src/bin/llm-cli/runtime/overlay/onboarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub enum OnboardingStep {
Welcome,
Provider,
ApiKey,
BaseUrl,
Preferences,
Confirm,
}
Expand All @@ -25,6 +26,7 @@ pub struct OnboardingState {
pub providers: Vec<OnboardingProvider>,
pub selected: usize,
pub api_key: String,
pub base_url: String,
pub mode: NavigationMode,
pub theme: String,
pub error: Option<String>,
Expand All @@ -37,6 +39,7 @@ impl OnboardingState {
providers,
selected: 0,
api_key: String::new(),
base_url: String::new(),
mode,
theme,
error: None,
Expand All @@ -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,
};
Expand All @@ -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;
Expand All @@ -84,4 +89,13 @@ impl OnboardingState {
pub fn set_error(&mut self, message: impl Into<String>) {
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,
}
})
}
}
Loading