diff --git a/assets/icons/ai_anthropic_compat.svg b/assets/icons/ai_anthropic_compat.svg new file mode 100644 index 0000000000..aae16efa05 --- /dev/null +++ b/assets/icons/ai_anthropic_compat.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 7c568873be..e79e5ba134 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2377,6 +2377,7 @@ "anthropic": { "api_url": "https://api.anthropic.com", }, + "anthropic_compatible": {}, "bedrock": {}, "google": { "api_url": "https://generativelanguage.googleapis.com", diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index f53b6752a0..65ddcafff4 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -31,7 +31,7 @@ use project::{ agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; -use settings::{Settings, SettingsStore, update_settings_file}; +use settings::{Settings, SettingsContent, SettingsStore, update_settings_file}; use ui::{ AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu, ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu, @@ -396,14 +396,7 @@ impl AgentConfiguration { update_settings_file(fs.clone(), cx, { let provider_id = provider_id.clone(); move |settings, _| { - if let Some(ref mut openai_compatible) = settings - .language_models - .as_mut() - .and_then(|lm| lm.openai_compatible.as_mut()) - { - let key_to_remove: Arc = Arc::from(provider_id.0.as_ref()); - openai_compatible.remove(&key_to_remove); - } + remove_compatible_provider(settings, provider_id.0.as_ref()); } }); }) @@ -444,22 +437,24 @@ impl AgentConfiguration { .menu({ let workspace = self.workspace.clone(); move |window, cx| { + let open_modal = |provider: LlmCompatibleProvider| { + let workspace = workspace.clone(); + move |window: &mut Window, cx: &mut App| { + workspace + .update(cx, |workspace, cx| { + AddLlmProviderModal::toggle(provider, workspace, window, cx); + }) + .log_err(); + } + }; Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.header("Compatible APIs").entry("OpenAI", None, { - let workspace = workspace.clone(); - move |window, cx| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle( - LlmCompatibleProvider::OpenAi, - workspace, - window, - cx, - ); - }) - .log_err(); - } - }) + menu.header("Compatible APIs") + .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi)) + .entry( + "Anthropic", + None, + open_modal(LlmCompatibleProvider::Anthropic), + ) })) } }) @@ -1535,13 +1530,144 @@ fn find_text_in_buffer( } } -// OpenAI-compatible providers are user-configured and can be removed, +// API-compatible providers are user-configured and can be removed, // whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't. // // If in the future we have more "API-compatible-type" of providers, // they should be included here as removable providers. fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool { - AllLanguageModelSettings::get_global(cx) + let settings = AllLanguageModelSettings::get_global(cx); + settings .openai_compatible .contains_key(provider_id.0.as_ref()) + || settings + .anthropic_compatible + .contains_key(provider_id.0.as_ref()) +} + +fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) { + // Mirrors the OpenAI-wins precedence used at registration time: only the + // entry that is actually registered gets removed. A shadowed + // `anthropic_compatible` entry with the same name takes over instead of + // being silently deleted. + let Some(language_models) = settings.language_models.as_mut() else { + return; + }; + let removed_from_openai = language_models + .openai_compatible + .as_mut() + .and_then(|providers| providers.remove(provider_id)) + .is_some(); + if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() { + providers.remove(provider_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use settings::{AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent}; + + fn settings_with_compatible_providers(openai: &[&str], anthropic: &[&str]) -> SettingsContent { + let mut settings = SettingsContent::default(); + let language_models = settings.language_models.get_or_insert_default(); + language_models.openai_compatible = Some( + openai + .iter() + .map(|id| { + ( + Arc::from(*id), + OpenAiCompatibleSettingsContent { + api_url: "https://example.com".to_string(), + available_models: Vec::new(), + custom_headers: None, + }, + ) + }) + .collect(), + ); + language_models.anthropic_compatible = Some( + anthropic + .iter() + .map(|id| { + ( + Arc::from(*id), + AnthropicCompatibleSettingsContent { + api_url: "https://example.com".to_string(), + available_models: Vec::new(), + custom_headers: None, + }, + ) + }) + .collect(), + ); + settings + } + + fn compatible_provider_keys(settings: &SettingsContent) -> (Vec<&str>, Vec<&str>) { + fn keys(providers: Option<&HashMap, T>>) -> Vec<&str> { + providers + .map(|providers| providers.keys().map(|key| key.as_ref()).collect()) + .unwrap_or_default() + } + + let language_models = settings + .language_models + .as_ref() + .expect("language_models settings should exist"); + ( + keys(language_models.openai_compatible.as_ref()), + keys(language_models.anthropic_compatible.as_ref()), + ) + } + + #[test] + fn test_remove_compatible_provider_openai_only() { + let mut settings = settings_with_compatible_providers(&["acme"], &[]); + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!(openai, Vec::<&str>::new()); + assert_eq!(anthropic, Vec::<&str>::new()); + } + + #[test] + fn test_remove_compatible_provider_anthropic_only() { + let mut settings = settings_with_compatible_providers(&[], &["acme"]); + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!(openai, Vec::<&str>::new()); + assert_eq!(anthropic, Vec::<&str>::new()); + } + + #[test] + fn test_remove_compatible_provider_collision_removes_only_openai_entry() { + let mut settings = settings_with_compatible_providers(&["acme"], &["acme"]); + + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!( + openai, + Vec::<&str>::new(), + "the registered (OpenAI-compatible) entry should be removed" + ); + assert_eq!( + anthropic, + vec!["acme"], + "the shadowed anthropic_compatible entry should survive" + ); + + // A second removal deletes the entry that took over. + remove_compatible_provider(&mut settings, "acme"); + let (_, anthropic) = compatible_provider_keys(&settings); + assert_eq!(anthropic, Vec::<&str>::new()); + } + + #[test] + fn test_remove_compatible_provider_leaves_other_providers_untouched() { + let mut settings = settings_with_compatible_providers(&["acme", "globex"], &["initech"]); + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!(openai, vec!["globex"]); + assert_eq!(anthropic, vec!["initech"]); + } } diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 99413e1063..86e556e2e5 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -1,14 +1,20 @@ use std::sync::Arc; use anyhow::Result; -use collections::HashSet; use fs::Fs; use gpui::{ DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt, }; +use itertools::Itertools as _; use language_model::LanguageModelRegistry; -use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities}; -use settings::{OpenAiCompatibleSettingsContent, update_settings_file}; +use language_models::provider::open_ai_compatible::{ + AvailableModel as OpenAiCompatibleAvailableModel, + ModelCapabilities as OpenAiCompatibleModelCapabilities, +}; +use settings::{ + AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, + AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, update_settings_file, +}; use ui::{ Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, @@ -40,20 +46,36 @@ fn single_line_input( #[derive(Clone, Copy)] pub enum LlmCompatibleProvider { OpenAi, + Anthropic, } impl LlmCompatibleProvider { fn name(&self) -> &'static str { match self { LlmCompatibleProvider::OpenAi => "OpenAI", + LlmCompatibleProvider::Anthropic => "Anthropic", } } fn api_url(&self) -> &'static str { match self { LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1", + LlmCompatibleProvider::Anthropic => "https://api.anthropic.com", } } + + fn description(&self) -> &'static str { + match self { + LlmCompatibleProvider::OpenAi => "This provider will use an OpenAI compatible API.", + LlmCompatibleProvider::Anthropic => { + "This provider will use an Anthropic Messages compatible API." + } + } + } + + fn is_open_ai(&self) -> bool { + matches!(self, LlmCompatibleProvider::OpenAi) + } } struct AddLlmProviderInput { @@ -151,14 +173,14 @@ impl ModelInput { cx, ); - let ModelCapabilities { + let OpenAiCompatibleModelCapabilities { tools, images, parallel_tool_calls, prompt_cache_key, chat_completions, .. - } = ModelCapabilities::default(); + } = OpenAiCompatibleModelCapabilities::default(); Self { name: model_name, @@ -175,36 +197,34 @@ impl ModelInput { } } - fn parse(&self, cx: &App) -> Result { + fn parse_name(&self, cx: &App) -> Result { let name = self.name.read(cx).text(cx); if name.is_empty() { return Err(SharedString::from("Model Name cannot be empty")); } - Ok(AvailableModel { - name, + Ok(name) + } + + fn parse_open_ai_compatible( + &self, + cx: &App, + ) -> Result { + Ok(OpenAiCompatibleAvailableModel { + name: self.parse_name(cx)?, display_name: None, - max_completion_tokens: Some( - self.max_completion_tokens - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from("Max Completion Tokens must be a number"))?, - ), - max_output_tokens: Some( - self.max_output_tokens - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from("Max Output Tokens must be a number"))?, - ), - max_tokens: self - .max_tokens - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from("Max Tokens must be a number"))?, + max_completion_tokens: Some(parse_u64_field( + &self.max_completion_tokens, + "Max Completion Tokens", + cx, + )?), + max_output_tokens: Some(parse_u64_field( + &self.max_output_tokens, + "Max Output Tokens", + cx, + )?), + max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, reasoning_effort: None, - capabilities: ModelCapabilities { + capabilities: OpenAiCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), @@ -214,9 +234,65 @@ impl ModelInput { }, }) } + + fn parse_anthropic_compatible( + &self, + cx: &App, + ) -> Result { + Ok(AnthropicCompatibleAvailableModel { + name: self.parse_name(cx)?, + display_name: None, + max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, + tool_override: None, + max_output_tokens: Some(parse_u64_field( + &self.max_output_tokens, + "Max Output Tokens", + cx, + )?), + default_temperature: None, + extra_beta_headers: Vec::new(), + mode: None, + capabilities: AnthropicCompatibleModelCapabilities { + tools: self.capabilities.supports_tools.selected(), + images: self.capabilities.supports_images.selected(), + prompt_caching: false, + }, + }) + } +} + +fn parse_u64_field( + field: &Entity, + field_name: &str, + cx: &App, +) -> Result { + field + .read(cx) + .text(cx) + .parse::() + .map_err(|_| SharedString::from(format!("{field_name} must be a number"))) +} + +enum ParsedModels { + OpenAi(Vec), + Anthropic(Vec), +} + +impl ParsedModels { + fn model_names(&self) -> impl Iterator { + match self { + ParsedModels::OpenAi(models) => { + itertools::Either::Left(models.iter().map(|model| model.name.as_str())) + } + ParsedModels::Anthropic(models) => { + itertools::Either::Right(models.iter().map(|model| model.name.as_str())) + } + } + } } fn save_provider_to_settings( + provider: LlmCompatibleProvider, input: &AddLlmProviderInput, cx: &mut App, ) -> Task> { @@ -248,18 +324,27 @@ fn save_provider_to_settings( return Task::ready(Err("API Key cannot be empty".into())); } - let mut models = Vec::new(); - let mut model_names: HashSet = HashSet::default(); - for model in &input.models { - match model.parse(cx) { - Ok(model) => { - if !model_names.insert(model.name.clone()) { - return Task::ready(Err("Model Names must be unique".into())); - } - models.push(model) - } - Err(err) => return Task::ready(Err(err)), - } + let models = match provider { + LlmCompatibleProvider::OpenAi => input + .models + .iter() + .map(|model| model.parse_open_ai_compatible(cx)) + .collect::, _>>() + .map(ParsedModels::OpenAi), + LlmCompatibleProvider::Anthropic => input + .models + .iter() + .map(|model| model.parse_anthropic_compatible(cx)) + .collect::, _>>() + .map(ParsedModels::Anthropic), + }; + let models = match models { + Ok(models) => models, + Err(error) => return Task::ready(Err(error)), + }; + + if !models.model_names().all_unique() { + return Task::ready(Err("Model Names must be unique".into())); } let fs = ::global(cx); @@ -268,20 +353,36 @@ fn save_provider_to_settings( task.await .map_err(|_| SharedString::from("Failed to write API key to keychain"))?; cx.update(|cx| { - update_settings_file(fs, cx, |settings, _cx| { - settings - .language_models - .get_or_insert_default() - .openai_compatible - .get_or_insert_default() - .insert( - provider_name, - OpenAiCompatibleSettingsContent { - api_url, - available_models: models, - custom_headers: None, - }, - ); + update_settings_file(fs, cx, move |settings, _cx| { + let language_models = settings.language_models.get_or_insert_default(); + match models { + ParsedModels::OpenAi(available_models) => { + language_models + .openai_compatible + .get_or_insert_default() + .insert( + provider_name, + OpenAiCompatibleSettingsContent { + api_url, + available_models, + custom_headers: None, + }, + ); + } + ParsedModels::Anthropic(available_models) => { + language_models + .anthropic_compatible + .get_or_insert_default() + .insert( + provider_name, + AnthropicCompatibleSettingsContent { + api_url, + available_models, + custom_headers: None, + }, + ); + } + } }); }); Ok(()) @@ -317,7 +418,7 @@ impl AddLlmProviderModal { } fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { - let task = save_provider_to_settings(&self.input, cx); + let task = save_provider_to_settings(self.provider, &self.input, cx); cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| match result { @@ -370,6 +471,7 @@ impl AddLlmProviderModal { fn render_model(&self, ix: usize, cx: &mut Context) -> impl IntoElement + use<> { let has_more_than_one_model = self.input.models.len() > 1; + let is_open_ai = self.provider.is_open_ai(); let model = &self.input.models[ix]; v_flex() @@ -384,7 +486,9 @@ impl AddLlmProviderModal { .child( h_flex() .gap_2() - .child(model.max_completion_tokens.clone()) + .when(is_open_ai, |parent| { + parent.child(model.max_completion_tokens.clone()) + }) .child(model.max_output_tokens.clone()), ) .child(model.max_tokens.clone()) @@ -407,49 +511,54 @@ impl AddLlmProviderModal { cx.notify(); })), ) - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, - ) - .label("Supports parallel_tool_calls") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_parallel_tool_calls = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-prompt-cache-key", ix), - model.capabilities.supports_prompt_cache_key, - ) - .label("Supports prompt_cache_key") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_prompt_cache_key = - *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-chat-completions", ix), - model.capabilities.supports_chat_completions, - ) - .label("Supports /chat/completions") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_chat_completions = - *checked; - cx.notify(); - }, - )), - ), + .when(is_open_ai, |parent| { + parent + .child( + Checkbox::new( + ("supports-parallel-tool-calls", ix), + model.capabilities.supports_parallel_tool_calls, + ) + .label("Supports parallel_tool_calls") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_parallel_tool_calls = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-prompt-cache-key", ix), + model.capabilities.supports_prompt_cache_key, + ) + .label("Supports prompt_cache_key") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_prompt_cache_key = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-chat-completions", ix), + model.capabilities.supports_chat_completions, + ) + .label("Supports /chat/completions") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_chat_completions = *checked; + cx.notify(); + }, + )), + ) + }), ) .when(has_more_than_one_model, |this| { this.child( @@ -521,13 +630,11 @@ impl Render for AddLlmProviderModal { })) .child( Modal::new("configure-context-server", None) - .header(ModalHeader::new().headline("Add LLM Provider").description( - match self.provider { - LlmCompatibleProvider::OpenAi => { - "This provider will use an OpenAI compatible API." - } - }, - )) + .header( + ModalHeader::new() + .headline("Add LLM Provider") + .description(self.provider.description()), + ) .when_some(self.last_error.clone(), |this, error| { this.section( Section::new().child( @@ -615,47 +722,102 @@ mod tests { async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) { let cx = setup_test(cx).await; - assert_eq!( - save_provider_validation_errors("", "someurl", "somekey", vec![], cx,).await, - Some("Provider Name cannot be empty".into()) - ); + for provider in [ + LlmCompatibleProvider::OpenAi, + LlmCompatibleProvider::Anthropic, + ] { + assert_eq!( + save_provider_validation_errors(provider, "", "someurl", "somekey", vec![], cx) + .await, + Some("Provider Name cannot be empty".into()) + ); - assert_eq!( - save_provider_validation_errors("someprovider", "", "somekey", vec![], cx,).await, - Some("API URL cannot be empty".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "", + "somekey", + vec![], + cx + ) + .await, + Some("API URL cannot be empty".into()) + ); - assert_eq!( - save_provider_validation_errors("someprovider", "someurl", "", vec![], cx,).await, - Some("API Key cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Model Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "abc", "200000", "32000")], - cx, - ) - .await, - Some("Max Tokens must be a number".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "", + vec![], + cx + ) + .await, + Some("API Key cannot be empty".into()) + ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![("", "200000", "200000", "32000")], + cx, + ) + .await, + Some("Model Name cannot be empty".into()) + ); + + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![("somemodel", "abc", "200000", "32000")], + cx, + ) + .await, + Some("Max Tokens must be a number".into()) + ); + + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![("somemodel", "200000", "200000", "abc")], + cx, + ) + .await, + Some("Max Output Tokens must be a number".into()) + ); + + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![ + ("somemodel", "200000", "200000", "32000"), + ("somemodel", "200000", "200000", "32000"), + ], + cx, + ) + .await, + Some("Model Names must be unique".into()) + ); + } + + // Max Completion Tokens is only used by OpenAI-compatible providers. assert_eq!( save_provider_validation_errors( + LlmCompatibleProvider::OpenAi, "someprovider", "someurl", "somekey", @@ -665,33 +827,6 @@ mod tests { .await, Some("Max Completion Tokens must be a number".into()) ); - - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "200000", "abc")], - cx, - ) - .await, - Some("Max Output Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![ - ("somemodel", "200000", "200000", "32000"), - ("somemodel", "200000", "200000", "32000"), - ], - cx, - ) - .await, - Some("Model Names must be unique".into()) - ); } #[gpui::test] @@ -712,6 +847,7 @@ mod tests { assert_eq!( save_provider_validation_errors( + LlmCompatibleProvider::OpenAi, "someprovider", "someurl", "someapikey", @@ -753,7 +889,7 @@ mod tests { ToggleState::Selected ); - let parsed_model = model_input.parse(cx).unwrap(); + let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(parsed_model.capabilities.tools); assert!(!parsed_model.capabilities.images); assert!(!parsed_model.capabilities.parallel_tool_calls); @@ -778,7 +914,7 @@ mod tests { model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Unselected; - let parsed_model = model_input.parse(cx).unwrap(); + let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(!parsed_model.capabilities.tools); assert!(!parsed_model.capabilities.images); assert!(!parsed_model.capabilities.parallel_tool_calls); @@ -803,7 +939,7 @@ mod tests { model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Selected; - let parsed_model = model_input.parse(cx).unwrap(); + let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert_eq!(parsed_model.name, "somemodel"); assert!(parsed_model.capabilities.tools); assert!(!parsed_model.capabilities.images); @@ -813,6 +949,32 @@ mod tests { }); } + #[gpui::test] + async fn test_model_input_parse_anthropic_compatible(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let mut model_input = ModelInput::new(0, window, cx); + model_input.name.update(cx, |input, cx| { + input.set_text("somemodel", window, cx); + }); + + let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); + assert_eq!(parsed_model.name, "somemodel"); + assert_eq!(parsed_model.max_tokens, 200000); + assert_eq!(parsed_model.max_output_tokens, Some(32000)); + assert!(parsed_model.capabilities.tools); + assert!(!parsed_model.capabilities.images); + + model_input.capabilities.supports_tools = ToggleState::Unselected; + model_input.capabilities.supports_images = ToggleState::Selected; + + let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); + assert!(!parsed_model.capabilities.tools); + assert!(parsed_model.capabilities.images); + }); + } + async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { cx.update(|cx| { let store = SettingsStore::test(cx); @@ -834,6 +996,7 @@ mod tests { } async fn save_provider_validation_errors( + provider: LlmCompatibleProvider, provider_name: &str, api_url: &str, api_key: &str, @@ -847,7 +1010,7 @@ mod tests { } let task = cx.update(|window, cx| { - let mut input = AddLlmProviderInput::new(LlmCompatibleProvider::OpenAi, window, cx); + let mut input = AddLlmProviderInput::new(provider, window, cx); set_text(&input.provider_name, provider_name, window, cx); set_text(&input.api_url, api_url, window, cx); set_text(&input.api_key, api_key, window, cx); @@ -869,7 +1032,7 @@ mod tests { ); set_text(&model.max_output_tokens, max_output_tokens, window, cx); } - save_provider_to_settings(&input, cx) + save_provider_to_settings(provider, &input, cx) }); task.await.err() diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 90b1a84180..33ad694509 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -957,73 +957,89 @@ impl From for Speed { impl From for language_model_core::LanguageModelCompletionError { fn from(error: AnthropicError) -> Self { - let provider = language_model_core::ANTHROPIC_PROVIDER_NAME; - match error { - AnthropicError::SerializeRequest(error) => Self::SerializeRequest { provider, error }, - AnthropicError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error }, - AnthropicError::HttpSend(error) => Self::HttpSend { provider, error }, - AnthropicError::DeserializeResponse(error) => { - Self::DeserializeResponse { provider, error } - } - AnthropicError::ReadResponse(error) => Self::ApiReadResponseError { provider, error }, - AnthropicError::HttpResponseError { - status_code, - message, - } => Self::HttpResponseError { - provider, - status_code, - message, - }, - AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded { - provider, - retry_after: Some(retry_after), - }, - AnthropicError::ServerOverloaded { retry_after } => Self::ServerOverloaded { - provider, - retry_after, - }, - AnthropicError::ApiError(api_error) => api_error.into(), - } + completion_error_from_anthropic(error, language_model_core::ANTHROPIC_PROVIDER_NAME) } } impl From for language_model_core::LanguageModelCompletionError { fn from(error: ApiError) -> Self { - use ApiErrorCode::*; - let provider = language_model_core::ANTHROPIC_PROVIDER_NAME; - match error.code() { - Some(code) => match code { - InvalidRequestError => Self::BadRequestFormat { - provider, - message: error.message, - }, - AuthenticationError => Self::AuthenticationError { - provider, - message: error.message, - }, - PermissionError => Self::PermissionError { - provider, - message: error.message, - }, - NotFoundError => Self::ApiEndpointNotFound { provider }, - RequestTooLarge => Self::PromptTooLarge { - tokens: language_model_core::parse_prompt_too_long(&error.message), - }, - RateLimitError => Self::RateLimitExceeded { - provider, - retry_after: None, - }, - ApiError => Self::ApiInternalServerError { - provider, - message: error.message, - }, - OverloadedError => Self::ServerOverloaded { - provider, - retry_after: None, - }, - }, - None => Self::Other(error.into()), + completion_error_from_anthropic_api(error, language_model_core::ANTHROPIC_PROVIDER_NAME) + } +} + +pub fn completion_error_from_anthropic( + error: AnthropicError, + provider: language_model_core::LanguageModelProviderName, +) -> language_model_core::LanguageModelCompletionError { + use language_model_core::LanguageModelCompletionError as Error; + match error { + AnthropicError::SerializeRequest(error) => Error::SerializeRequest { provider, error }, + AnthropicError::BuildRequestBody(error) => Error::BuildRequestBody { provider, error }, + AnthropicError::HttpSend(error) => Error::HttpSend { provider, error }, + AnthropicError::DeserializeResponse(error) => { + Error::DeserializeResponse { provider, error } } + AnthropicError::ReadResponse(error) => Error::ApiReadResponseError { provider, error }, + AnthropicError::HttpResponseError { + status_code, + message, + } => Error::HttpResponseError { + provider, + status_code, + message, + }, + AnthropicError::RateLimit { retry_after } => Error::RateLimitExceeded { + provider, + retry_after: Some(retry_after), + }, + AnthropicError::ServerOverloaded { retry_after } => Error::ServerOverloaded { + provider, + retry_after, + }, + AnthropicError::ApiError(api_error) => { + completion_error_from_anthropic_api(api_error, provider) + } + } +} + +pub fn completion_error_from_anthropic_api( + error: ApiError, + provider: language_model_core::LanguageModelProviderName, +) -> language_model_core::LanguageModelCompletionError { + use ApiErrorCode::*; + use language_model_core::LanguageModelCompletionError as Error; + match error.code() { + Some(code) => match code { + InvalidRequestError => Error::BadRequestFormat { + provider, + message: error.message, + }, + AuthenticationError => Error::AuthenticationError { + provider, + message: error.message, + }, + PermissionError => Error::PermissionError { + provider, + message: error.message, + }, + NotFoundError => Error::ApiEndpointNotFound { provider }, + RequestTooLarge => Error::PromptTooLarge { + tokens: language_model_core::parse_prompt_too_long(&error.message), + }, + RateLimitError => Error::RateLimitExceeded { + provider, + retry_after: None, + }, + ApiError => Error::ApiInternalServerError { + provider, + message: error.message, + }, + OverloadedError => Error::ServerOverloaded { + provider, + retry_after: None, + }, + }, + None => Error::Other(error.into()), } } diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 2b42bcae88..76cbbec580 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -2,9 +2,9 @@ use anyhow::Result; use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - Role, StopReason, TokenUsage, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelProviderName, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, Role, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; @@ -14,6 +14,7 @@ use crate::{ AdaptiveThinkingDisplay, AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, CacheTtl, ContentDelta, Event, ImageSource, Message, RequestContent, ResponseContent, StringOrContents, Thinking, Tool, ToolChoice, ToolResultContent, ToolResultPart, Usage, + completion_error_from_anthropic, completion_error_from_anthropic_api, }; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -323,14 +324,16 @@ pub struct AnthropicEventMapper { tool_uses_by_index: HashMap, usage: Usage, stop_reason: StopReason, + provider_name: LanguageModelProviderName, } impl AnthropicEventMapper { - pub fn new() -> Self { + pub fn new(provider_name: LanguageModelProviderName) -> Self { Self { tool_uses_by_index: HashMap::default(), usage: Usage::default(), stop_reason: StopReason::EndTurn, + provider_name, } } @@ -342,7 +345,10 @@ impl AnthropicEventMapper { events.flat_map(move |event| { futures::stream::iter(match event { Ok(event) => self.map_event(event), - Err(error) => vec![Err(error.into())], + Err(error) => vec![Err(completion_error_from_anthropic( + error, + self.provider_name.clone(), + ))], }) }) } @@ -484,7 +490,10 @@ impl AnthropicEventMapper { vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))] } Event::Error { error } => { - vec![Err(error.into())] + vec![Err(completion_error_from_anthropic_api( + error, + self.provider_name.clone(), + ))] } _ => Vec::new(), } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 68314ccff2..34206b4767 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -10,6 +10,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr}; pub enum IconName { AcpRegistry, AiAnthropic, + AiAnthropicCompat, AiBedrock, AiClaude, AiDeepSeek, diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 97ac0b2c0a..717da683dc 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; -use collections::HashSet; +use collections::{HashMap, HashSet}; use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; use language_model::{ @@ -17,6 +17,7 @@ mod settings; pub use crate::extension::init_proxy as init_extension_proxy; use crate::provider::anthropic::AnthropicLanguageModelProvider; +use crate::provider::anthropic_compatible::AnthropicCompatibleLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; use crate::provider::cloud::CloudLanguageModelProvider; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; @@ -102,19 +103,15 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { }); } - let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx) - .openai_compatible - .keys() - .cloned() - .collect::>(); + let mut compatible_providers = CompatibleProviders::from_settings(cx); registry.update(cx, |registry, cx| { - register_openai_compatible_providers( + register_compatible_providers( registry, - &HashSet::default(), - &openai_compatible_providers, - client.clone(), - credentials_provider.clone(), + &CompatibleProviders::default(), + &compatible_providers, + &client, + &credentials_provider, cx, ); }); @@ -124,23 +121,19 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { let Some(registry) = registry.upgrade() else { return; }; - let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) - .openai_compatible - .keys() - .cloned() - .collect::>(); - if openai_compatible_providers_new != openai_compatible_providers { + let compatible_providers_new = CompatibleProviders::from_settings(cx); + if compatible_providers_new != compatible_providers { registry.update(cx, |registry, cx| { - register_openai_compatible_providers( + register_compatible_providers( registry, - &openai_compatible_providers, - &openai_compatible_providers_new, - client.clone(), - credentials_provider.clone(), + &compatible_providers, + &compatible_providers_new, + &client, + &credentials_provider, cx, ); }); - openai_compatible_providers = openai_compatible_providers_new; + compatible_providers = compatible_providers_new; } }) .detach(); @@ -190,31 +183,77 @@ pub fn update_environment_fallback_model(cx: &mut App) { }); } -fn register_openai_compatible_providers( +#[derive(Default, PartialEq, Eq)] +struct CompatibleProviders(HashMap, CompatibleProviderKind>); + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum CompatibleProviderKind { + OpenAi, + Anthropic, +} + +impl CompatibleProviders { + fn from_settings(cx: &App) -> Self { + let settings = AllLanguageModelSettings::get_global(cx); + let mut providers: HashMap, CompatibleProviderKind> = settings + .openai_compatible + .keys() + .map(|id| (id.clone(), CompatibleProviderKind::OpenAi)) + .collect(); + for id in settings.anthropic_compatible.keys() { + // The registry has a single provider ID namespace, so a name can + // only refer to one provider. OpenAI-compatible entries win + // collisions because they predate Anthropic-compatible ones, so + // existing configurations keep working. + if providers.contains_key(id) { + log::warn!( + "ignoring `anthropic_compatible` provider `{id}`: \ + an `openai_compatible` provider with the same name exists" + ); + } else { + providers.insert(id.clone(), CompatibleProviderKind::Anthropic); + } + } + Self(providers) + } +} + +fn register_compatible_providers( registry: &mut LanguageModelRegistry, - old: &HashSet>, - new: &HashSet>, - client: Arc, - credentials_provider: Arc, + old: &CompatibleProviders, + new: &CompatibleProviders, + client: &Arc, + credentials_provider: &Arc, cx: &mut Context, ) { - for provider_id in old { - if !new.contains(provider_id) { + for (provider_id, old_kind) in &old.0 { + if new.0.get(provider_id) != Some(old_kind) { registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); } } - for provider_id in new { - if !old.contains(provider_id) { - registry.register_provider( - Arc::new(OpenAiCompatibleLanguageModelProvider::new( - provider_id.clone(), - client.http_client(), - credentials_provider.clone(), + for (provider_id, kind) in &new.0 { + if old.0.get(provider_id) != Some(kind) { + match kind { + CompatibleProviderKind::OpenAi => registry.register_provider( + Arc::new(OpenAiCompatibleLanguageModelProvider::new( + provider_id.clone(), + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, - )), - cx, - ); + ), + CompatibleProviderKind::Anthropic => registry.register_provider( + Arc::new(AnthropicCompatibleLanguageModelProvider::new( + provider_id.clone(), + client.http_client(), + credentials_provider.clone(), + cx, + )), + cx, + ), + } } } } @@ -340,3 +379,209 @@ fn register_language_model_providers( cx, ); } + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use clock::FakeSystemClock; + use feature_flags::FeatureFlagAppExt as _; + use gpui::{AppContext as _, AsyncApp, BorrowAppContext as _}; + use http_client::FakeHttpClient; + use language_model::IconOrSvg; + use release_channel::AppVersion; + use std::future::Future; + use std::pin::Pin; + use ui::IconName; + + struct FakeCredentialsProvider; + + impl CredentialsProvider for FakeCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + Box::pin(async { Ok(None) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + _username: &'a str, + _password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + } + + fn init_test(cx: &mut App) -> (Arc, Arc) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + cx.set_global(db::AppDatabase::test_new()); + let app_version = AppVersion::global(cx); + release_channel::init_test(app_version, release_channel::ReleaseChannel::Dev, cx); + gpui_tokio::init(cx); + cx.update_flags(false, Vec::new()); + + let client = Client::new( + Arc::new(FakeSystemClock::new()), + FakeHttpClient::with_404_response(), + cx, + ); + (client, Arc::new(FakeCredentialsProvider)) + } + + fn update_compatible_provider_settings( + openai: &[&str], + anthropic: &[&str], + cx: &mut App, + ) -> CompatibleProviders { + fn section(ids: &[&str]) -> serde_json::Value { + ids.iter() + .map(|id| { + ( + id.to_string(), + serde_json::json!({ + "api_url": "https://example.com", + "available_models": [], + }), + ) + }) + .collect::>() + .into() + } + + let content = serde_json::json!({ + "language_models": { + "openai_compatible": section(openai), + "anthropic_compatible": section(anthropic), + } + }) + .to_string(); + cx.update_global::(|store, cx| { + store + .set_user_settings(&content, cx) + .expect("failed to parse test settings"); + }); + CompatibleProviders::from_settings(cx) + } + + fn provider_icons(registry: &LanguageModelRegistry, id: &str) -> Vec { + registry + .providers() + .into_iter() + .filter(|provider| provider.id().0.as_ref() == id) + .map(|provider| provider.icon()) + .collect() + } + + #[gpui::test] + fn test_compatible_provider_id_collision_resolves_when_one_entry_is_removed(cx: &mut App) { + let (client, credentials_provider) = init_test(cx); + let registry = cx.new(|_| LanguageModelRegistry::default()); + + // The same provider name is configured in both `openai_compatible` + // and `anthropic_compatible` settings sections; the OpenAI-compatible + // entry wins the collision. + let both = update_compatible_provider_settings(&["acme"], &["acme"], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &CompatibleProviders::default(), + &both, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + vec![IconOrSvg::Icon(IconName::AiOpenAiCompat)], + "the OpenAI-compatible provider should win the name collision" + ); + + // The user removes the `anthropic_compatible` entry; the remaining + // `openai_compatible` entry must stay registered. + let openai_only = update_compatible_provider_settings(&["acme"], &[], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &both, + &openai_only, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + vec![IconOrSvg::Icon(IconName::AiOpenAiCompat)], + "the provider registered for `acme` should be the OpenAI-compatible one" + ); + } + + #[gpui::test] + fn test_compatible_provider_changes_kind_and_unregisters(cx: &mut App) { + let (client, credentials_provider) = init_test(cx); + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let both = update_compatible_provider_settings(&["acme"], &["acme"], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &CompatibleProviders::default(), + &both, + &client, + &credentials_provider, + cx, + ); + }); + + // Removing the `openai_compatible` entry hands the name over to the + // remaining `anthropic_compatible` entry. + let anthropic_only = update_compatible_provider_settings(&[], &["acme"], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &both, + &anthropic_only, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + vec![IconOrSvg::Icon(IconName::AiAnthropicCompat)], + "after removing the openai_compatible entry, the anthropic_compatible provider should be registered" + ); + + // Removing the last entry unregisters the provider entirely. + let none = update_compatible_provider_settings(&[], &[], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &anthropic_only, + &none, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + Vec::new(), + "removing all entries should unregister the provider" + ); + } +} diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index 51b323e6c7..3a1369a2d1 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -3,6 +3,8 @@ use http_client::CustomHeaders; use http_client::http::{HeaderName, HeaderValue}; pub mod anthropic; +pub mod anthropic_compatible; +pub mod api_compatible; pub mod bedrock; pub mod cloud; pub mod copilot_chat; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 6ac0dd18f8..47af84de12 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -539,7 +539,7 @@ impl LanguageModel for AnthropicModel { let request = self.stream_completion(request, cx); let future = self.request_limiter.stream(async move { let response = request.await?; - Ok(AnthropicEventMapper::new().map_stream(response)) + Ok(AnthropicEventMapper::new(PROVIDER_NAME).map_stream(response)) }); async move { Ok(future.await?.boxed()) }.boxed() } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs new file mode 100644 index 0000000000..afc29e2b4a --- /dev/null +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -0,0 +1,353 @@ +use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; +use anthropic::{AnthropicError, AnthropicModelMode}; +use anyhow::Result; +use credentials_provider::CredentialsProvider; +use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; +use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; +use http_client::{CustomHeaders, HttpClient}; +use language_model::{ + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, +}; +use settings::Settings; +use std::sync::Arc; +use ui::IconName; + +use crate::provider::api_compatible::{ + ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, + ApiCompatibleProviderState, +}; + +pub use settings::AnthropicCompatibleAvailableModel as AvailableModel; +pub use settings::AnthropicCompatibleModelCapabilities as ModelCapabilities; + +const API_KEY_PLACEHOLDER: &str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + +#[derive(Default, Clone, Debug, PartialEq)] +pub struct AnthropicCompatibleSettings { + pub api_url: String, + pub available_models: Vec, + pub custom_headers: CustomHeaders, +} + +pub struct AnthropicCompatibleLanguageModelProvider { + id: LanguageModelProviderId, + name: LanguageModelProviderName, + http_client: Arc, + state: Entity, +} + +impl ApiCompatibleProviderSettings for AnthropicCompatibleSettings { + fn api_url(&self) -> &str { + &self.api_url + } +} + +pub type State = ApiCompatibleProviderState; + +fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::Model { + let mode = match available.mode.unwrap_or_default() { + settings::ModelMode::Default => AnthropicModelMode::Default, + settings::ModelMode::Thinking { budget_tokens } => { + AnthropicModelMode::Thinking { budget_tokens } + } + }; + let supports_thinking = matches!(mode, AnthropicModelMode::Thinking { .. }); + + anthropic::Model { + display_name: available + .display_name + .clone() + .unwrap_or_else(|| available.name.clone()), + id: available.name.clone(), + max_input_tokens: available.max_tokens, + max_output_tokens: available.max_output_tokens.unwrap_or(4_096), + default_temperature: available.default_temperature.unwrap_or(1.0), + mode, + supports_thinking, + supports_adaptive_thinking: false, + supports_images: available.capabilities.images, + supports_speed: false, + supported_effort_levels: Vec::new(), + tool_override: available.tool_override.clone(), + extra_beta_headers: available.extra_beta_headers.clone(), + } +} + +impl AnthropicCompatibleLanguageModelProvider { + pub fn new( + id: Arc, + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { + let state = State::new( + id.clone(), + credentials_provider, + |id, cx| { + crate::AllLanguageModelSettings::get_global(cx) + .anthropic_compatible + .get(id) + }, + cx, + ); + + Self { + id: id.clone().into(), + name: id.into(), + http_client, + state, + } + } + + fn create_language_model(&self, model: AvailableModel) -> Arc { + let capabilities = model.capabilities.clone(); + // Compatible providers may not support Anthropic's automatic prompt + // caching; only request explicit (legacy) cache breakpoints when the + // user has opted in via the `prompt_caching` capability. + let cache_mode = if capabilities.prompt_caching { + AnthropicPromptCacheMode::Legacy + } else { + AnthropicPromptCacheMode::Disabled + }; + let model = available_model_to_anthropic_model(&model); + + Arc::new(AnthropicCompatibleLanguageModel { + id: LanguageModelId::from(model.id.clone()), + provider_id: self.id.clone(), + provider_name: self.name.clone(), + model, + capabilities, + cache_mode, + state: self.state.clone(), + http_client: self.http_client.clone(), + request_limiter: RateLimiter::new(4), + }) + } +} + +impl LanguageModelProviderState for AnthropicCompatibleLanguageModelProvider { + type ObservableEntity = State; + + fn observable_entity(&self) -> Option> { + Some(self.state.clone()) + } +} + +impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { + fn id(&self) -> LanguageModelProviderId { + self.id.clone() + } + + fn name(&self) -> LanguageModelProviderName { + self.name.clone() + } + + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiAnthropicCompat) + } + + fn default_model(&self, cx: &App) -> Option> { + self.state + .read(cx) + .settings + .available_models + .first() + .map(|model| self.create_language_model(model.clone())) + } + + fn default_fast_model(&self, _cx: &App) -> Option> { + None + } + + fn provided_models(&self, cx: &App) -> Vec> { + self.state + .read(cx) + .settings + .available_models + .iter() + .map(|model| self.create_language_model(model.clone())) + .collect() + } + + fn is_authenticated(&self, cx: &App) -> bool { + self.state.read(cx).is_authenticated() + } + + fn authenticate(&self, cx: &mut App) -> Task> { + self.state.update(cx, |state, cx| state.authenticate(cx)) + } + + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + self.state.clone(), + "Anthropic", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + } + + fn reset_credentials(&self, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) + } +} + +pub struct AnthropicCompatibleLanguageModel { + id: LanguageModelId, + provider_id: LanguageModelProviderId, + provider_name: LanguageModelProviderName, + model: anthropic::Model, + capabilities: ModelCapabilities, + cache_mode: AnthropicPromptCacheMode, + state: Entity, + http_client: Arc, + request_limiter: RateLimiter, +} + +impl AnthropicCompatibleLanguageModel { + fn stream_completion( + &self, + request: anthropic::Request, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let http_client = self.http_client.clone(); + let provider_name = self.provider_name.clone(); + + let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, _cx| { + let api_url = state.settings.api_url.clone(); + ( + state.api_key_state.key(&api_url), + api_url, + state.settings.custom_headers.clone(), + ) + }); + + let beta_headers = self.model.beta_headers(); + + async move { + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: provider_name, + }); + }; + + let request = anthropic::stream_completion( + http_client.as_ref(), + &api_url, + &api_key, + request, + beta_headers, + &extra_headers, + ); + + request + .await + .map_err(|error| anthropic::completion_error_from_anthropic(error, provider_name)) + } + .boxed() + } +} + +impl LanguageModel for AnthropicCompatibleLanguageModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name.clone()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + self.provider_id.clone() + } + + fn provider_name(&self) -> LanguageModelProviderName { + self.provider_name.clone() + } + + fn supports_tools(&self) -> bool { + self.capabilities.tools + } + + fn supports_images(&self) -> bool { + self.capabilities.images + } + + fn supports_streaming_tools(&self) -> bool { + self.capabilities.tools + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => self.capabilities.tools, + LanguageModelToolChoice::None => true, + } + } + + fn supports_thinking(&self) -> bool { + self.model.supports_thinking + } + + fn telemetry_id(&self) -> String { + format!("anthropic/{}", self.model.id) + } + + fn max_token_count(&self) -> u64 { + self.model.max_input_tokens + } + + fn max_output_tokens(&self) -> Option { + Some(self.model.max_output_tokens) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let has_tools = !request.tools.is_empty(); + let request_id = self.model.request_id(has_tools).to_string(); + let mut request = into_anthropic( + request, + request_id, + self.model.default_temperature, + self.model.max_output_tokens, + self.model.mode.clone(), + self.cache_mode, + ); + if !self.model.supports_speed { + request.speed = None; + } + let completion_request = self.stream_completion(request, cx); + let provider_name = self.provider_name.clone(); + let future = self.request_limiter.stream(async move { + let response = completion_request.await?; + Ok(AnthropicEventMapper::new(provider_name).map_stream(response)) + }); + async move { Ok(future.await?.boxed()) }.boxed() + } +} diff --git a/crates/language_models/src/provider/api_compatible.rs b/crates/language_models/src/provider/api_compatible.rs new file mode 100644 index 0000000000..e186f0baf9 --- /dev/null +++ b/crates/language_models/src/provider/api_compatible.rs @@ -0,0 +1,262 @@ +use std::sync::Arc; + +use ::util::ResultExt; +use anyhow::Result; +use convert_case::{Case, Casing}; +use credentials_provider::CredentialsProvider; +use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, TaskExt, Window}; +use language_model::{ApiKeyState, AuthenticateError, EnvVar}; +use settings::SettingsStore; +use ui::{ElevationIndex, Tooltip, prelude::*}; +use ui_input::InputField; + +pub trait ApiCompatibleProviderSettings: Clone + Default + PartialEq + 'static { + fn api_url(&self) -> &str; +} + +pub struct ApiCompatibleProviderState { + id: Arc, + pub api_key_state: ApiKeyState, + pub settings: S, + credentials_provider: Arc, +} + +impl ApiCompatibleProviderState { + pub fn new( + id: Arc, + credentials_provider: Arc, + resolve_settings: for<'a> fn(&'a str, &'a App) -> Option<&'a S>, + cx: &mut App, + ) -> Entity { + let api_key_env_var_name: SharedString = + format!("{}_API_KEY", id).to_case(Case::UpperSnake).into(); + cx.new(|cx| { + cx.observe_global::(move |this: &mut Self, cx| { + let Some(settings) = resolve_settings(&this.id, cx).cloned() else { + return; + }; + this.update_settings(settings, cx); + }) + .detach(); + + let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); + Self { + id, + api_key_state: ApiKeyState::new( + SharedString::new(settings.api_url()), + EnvVar::new(api_key_env_var_name), + ), + settings, + credentials_provider, + } + }) + } + + pub fn is_authenticated(&self) -> bool { + self.api_key_state.has_key() + } + + pub fn set_api_key( + &mut self, + api_key: Option, + cx: &mut Context, + ) -> Task> { + let api_url = SharedString::new(self.settings.api_url()); + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + self.credentials_provider.clone(), + cx, + ) + } + + pub fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = SharedString::new(self.settings.api_url()); + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + self.credentials_provider.clone(), + cx, + ) + } + + pub fn update_settings(&mut self, settings: S, cx: &mut Context) { + if self.settings != settings { + let api_url = SharedString::new(settings.api_url()); + self.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + self.credentials_provider.clone(), + cx, + ); + self.settings = settings; + cx.notify(); + } + } +} + +pub struct ApiCompatibleProviderConfigurationView { + api_key_editor: Entity, + state: Entity>, + provider_name: &'static str, + load_credentials_task: Option>, +} + +impl ApiCompatibleProviderConfigurationView { + pub fn new( + state: Entity>, + provider_name: &'static str, + placeholder_text: &'static str, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let api_key_editor = cx.new(|cx| InputField::new(window, cx, placeholder_text)); + + cx.observe(&state, |_, _, cx| { + cx.notify(); + }) + .detach(); + + let load_credentials_task = Some(cx.spawn_in(window, { + let state = state.clone(); + async move |this, cx| { + let task = state.update(cx, |state, cx| state.authenticate(cx)); + match task.await { + Ok(()) | Err(AuthenticateError::CredentialsNotFound) => {} + Err(error) => { + log::error!( + "Failed to load {provider_name}-compatible provider API credentials: {error}" + ); + } + } + this.update(cx, |this, cx| { + this.load_credentials_task = None; + cx.notify(); + }) + .log_err(); + } + })); + + Self { + api_key_editor, + state, + provider_name, + load_credentials_task, + } + } + + fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { + return; + } + + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) + .await + }) + .detach_and_log_err(cx); + } + + fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(None, cx)) + .await + }) + .detach_and_log_err(cx); + } + + fn should_render_editor(&self, cx: &Context) -> bool { + !self.state.read(cx).is_authenticated() + } +} + +impl Render for ApiCompatibleProviderConfigurationView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = self.state.read(cx); + let env_var_set = state.api_key_state.is_from_env_var(); + let env_var_name = state.api_key_state.env_var_name(); + let provider_name = self.provider_name; + + let api_key_section = if self.should_render_editor(cx) { + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(Label::new(format!( + "To use Zed's agent with an {provider_name}-compatible provider, you need to add an API key." + ))) + .child( + div() + .pt(DynamicSpacing::Base04.rems(cx)) + .child(self.api_key_editor.clone()), + ) + .child( + Label::new(format!( + "You can also set the {env_var_name} environment variable and restart Zed.", + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any() + } else { + h_flex() + .mt_1() + .p_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child( + div().w_full().overflow_x_hidden().text_ellipsis().child(Label::new( + if env_var_set { + format!("API key set in {env_var_name} environment variable") + } else { + format!("API key configured for {}", state.settings.api_url()) + }, + )), + ), + ) + .child( + h_flex().flex_shrink_0().child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!( + "To reset your API key, unset the {env_var_name} environment variable.", + ))) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_api_key(window, cx) + })), + ), + ) + .into_any() + }; + + if self.load_credentials_task.is_some() { + div().child(Label::new("Loading credentials…")).into_any() + } else { + v_flex().size_full().child(api_key_section).into_any() + } + } +} diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index f42fad657c..43067965ca 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -400,7 +400,7 @@ impl LanguageModel for CopilotChatLanguageModel { request_limiter .stream(async move { let events = stream.await?; - let mapper = AnthropicEventMapper::new(); + let mapper = AnthropicEventMapper::new(PROVIDER_NAME); Ok(mapper.map_stream(events).boxed()) }) .await diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 51f277be33..c1f9f70a15 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,33 +1,35 @@ use anyhow::Result; -use convert_case::{Case, Casing}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, }; -use menu; use open_ai::{ ResponseStreamEvent, responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response}, stream_completion, }; -use settings::{Settings, SettingsStore}; +use settings::Settings; use std::sync::Arc; -use ui::{ElevationIndex, Tooltip, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; +use crate::provider::api_compatible::{ + ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, + ApiCompatibleProviderState, +}; use crate::provider::open_ai::{ OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; +const API_KEY_PLACEHOLDER: &str = "000000000000000000000000000000000000000000000000000"; + #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenAiCompatibleSettings { pub api_url: String, @@ -35,6 +37,14 @@ pub struct OpenAiCompatibleSettings { pub custom_headers: CustomHeaders, } +impl ApiCompatibleProviderSettings for OpenAiCompatibleSettings { + fn api_url(&self) -> &str { + &self.api_url + } +} + +pub type State = ApiCompatibleProviderState; + pub struct OpenAiCompatibleLanguageModelProvider { id: LanguageModelProviderId, name: LanguageModelProviderName, @@ -42,42 +52,6 @@ pub struct OpenAiCompatibleLanguageModelProvider { state: Entity, } -pub struct State { - id: Arc, - api_key_state: ApiKeyState, - settings: OpenAiCompatibleSettings, - credentials_provider: Arc, -} - -impl State { - fn is_authenticated(&self) -> bool { - self.api_key_state.has_key() - } - - fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { - let credentials_provider = self.credentials_provider.clone(); - let api_url = SharedString::new(self.settings.api_url.as_str()); - self.api_key_state.store( - api_url, - api_key, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ) - } - - fn authenticate(&mut self, cx: &mut Context) -> Task> { - let credentials_provider = self.credentials_provider.clone(); - let api_url = SharedString::new(self.settings.api_url.clone()); - self.api_key_state.load_if_needed( - api_url, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ) - } -} - impl OpenAiCompatibleLanguageModelProvider { pub fn new( id: Arc, @@ -85,43 +59,16 @@ impl OpenAiCompatibleLanguageModelProvider { credentials_provider: Arc, cx: &mut App, ) -> Self { - fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> { - crate::AllLanguageModelSettings::get_global(cx) - .openai_compatible - .get(id) - } - - let api_key_env_var_name = format!("{}_API_KEY", id).to_case(Case::UpperSnake).into(); - let state = cx.new(|cx| { - cx.observe_global::(|this: &mut State, cx| { - let Some(settings) = resolve_settings(&this.id, cx).cloned() else { - return; - }; - if &this.settings != &settings { - let credentials_provider = this.credentials_provider.clone(); - let api_url = SharedString::new(settings.api_url.as_str()); - this.api_key_state.handle_url_change( - api_url, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ); - this.settings = settings; - cx.notify(); - } - }) - .detach(); - let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); - State { - id: id.clone(), - api_key_state: ApiKeyState::new( - SharedString::new(settings.api_url.as_str()), - EnvVar::new(api_key_env_var_name), - ), - settings, - credentials_provider, - } - }); + let state = State::new( + id.clone(), + credentials_provider, + |id, cx| { + crate::AllLanguageModelSettings::get_global(cx) + .openai_compatible + .get(id) + }, + cx, + ); Self { id: id.clone().into(), @@ -202,8 +149,16 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { window: &mut Window, cx: &mut App, ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + self.state.clone(), + "OpenAI", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { @@ -416,159 +371,3 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "000000000000000000000000000000000000000000000000000", - ) - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let state = self.state.read(cx); - let env_var_set = state.api_key_state.is_from_env_var(); - let env_var_name = state.api_key_state.env_var_name(); - - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with an OpenAI-compatible provider, you need to add an API key.")) - .child( - div() - .pt(DynamicSpacing::Base04.rems(cx)) - .child(self.api_key_editor.clone()) - ) - .child( - Label::new( - format!("You can also set the {env_var_name} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any() - } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new( - if env_var_set { - format!("API key set in {env_var_name} environment variable") - } else { - format!("API key configured for {}", &state.settings.api_url) - } - )) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials…")).into_any() - } else { - v_flex().size_full().child(api_key_section).into_any() - } - } -} diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 28501560c2..7ee2d980ed 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -673,7 +673,7 @@ impl LanguageModel for OpenCodeLanguageModel { let stream = self.stream_anthropic(anthropic_request, http_client, extra_headers, cx); async move { - let mapper = AnthropicEventMapper::new(); + let mapper = AnthropicEventMapper::new(PROVIDER_NAME); Ok(mapper.map_stream(stream.await?).boxed()) } .boxed() diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 9e3f6d708b..d2fc9f59a2 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -4,17 +4,18 @@ use collections::HashMap; use settings::RegisterSetting; use crate::provider::{ - anthropic, anthropic::AnthropicSettings, bedrock, bedrock::AmazonBedrockSettings, - cloud::ZedDotDevSettings, deepseek::DeepSeekSettings, google::GoogleSettings, - lmstudio::LmStudioSettings, mistral, mistral::MistralSettings, ollama::OllamaSettings, - open_ai::OpenAiSettings, open_ai_compatible::OpenAiCompatibleSettings, open_router, - open_router::OpenRouterSettings, opencode, opencode::OpenCodeSettings, resolve_custom_headers, - vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings, + anthropic, anthropic::AnthropicSettings, anthropic_compatible::AnthropicCompatibleSettings, + bedrock, bedrock::AmazonBedrockSettings, cloud::ZedDotDevSettings, deepseek::DeepSeekSettings, + google::GoogleSettings, lmstudio::LmStudioSettings, mistral, mistral::MistralSettings, + ollama::OllamaSettings, open_ai::OpenAiSettings, open_ai_compatible::OpenAiCompatibleSettings, + open_router, open_router::OpenRouterSettings, opencode, opencode::OpenCodeSettings, + resolve_custom_headers, vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings, }; #[derive(Debug, RegisterSetting)] pub struct AllLanguageModelSettings { pub anthropic: AnthropicSettings, + pub anthropic_compatible: HashMap, AnthropicCompatibleSettings>, pub bedrock: AmazonBedrockSettings, pub deepseek: DeepSeekSettings, pub google: GoogleSettings, @@ -47,6 +48,7 @@ impl settings::Settings for AllLanguageModelSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let language_models = content.language_models.clone().unwrap(); let anthropic = language_models.anthropic.unwrap(); + let anthropic_compatible = language_models.anthropic_compatible.unwrap(); let bedrock = language_models.bedrock.unwrap(); let deepseek = language_models.deepseek.unwrap(); let google = language_models.google.unwrap(); @@ -70,6 +72,24 @@ impl settings::Settings for AllLanguageModelSettings { anthropic::RESERVED_HEADER_NAMES, ), }, + anthropic_compatible: anthropic_compatible + .into_iter() + .map(|(key, value)| { + let provider_label = format!("Anthropic Compatible ({key})"); + ( + key, + AnthropicCompatibleSettings { + api_url: value.api_url, + available_models: value.available_models, + custom_headers: custom_headers_from( + &provider_label, + value.custom_headers, + anthropic::RESERVED_HEADER_NAMES, + ), + }, + ) + }) + .collect(), bedrock: AmazonBedrockSettings { available_models: bedrock.available_models.unwrap_or_default(), custom_headers: custom_headers_from( diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index d4598b89e7..c4ed55d853 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -501,7 +501,7 @@ impl LanguageModel for CloudLanguageModel, + pub anthropic_compatible: Option, AnthropicCompatibleSettingsContent>>, pub bedrock: Option, pub deepseek: Option, pub google: Option, @@ -35,6 +36,56 @@ pub struct AnthropicSettingsContent { pub custom_headers: Option>, } +#[with_fallible_options] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] +pub struct AnthropicCompatibleSettingsContent { + pub api_url: String, + pub available_models: Vec, + pub custom_headers: Option>, +} + +#[with_fallible_options] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct AnthropicCompatibleAvailableModel { + /// The model's name in the provider's API. e.g. claude-3-5-sonnet-latest + pub name: String, + /// The model's name in Zed's UI, such as in the model selector dropdown menu in the assistant panel. + pub display_name: Option, + /// The model's context window size. + pub max_tokens: u64, + /// A model `name` to substitute when calling tools, in case the primary model doesn't support tool calling. + pub tool_override: Option, + pub max_output_tokens: Option, + #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] + pub default_temperature: Option, + #[serde(default)] + pub extra_beta_headers: Vec, + /// The model's mode (e.g. thinking) + pub mode: Option, + #[serde(default)] + pub capabilities: AnthropicCompatibleModelCapabilities, +} + +#[with_fallible_options] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct AnthropicCompatibleModelCapabilities { + pub tools: bool, + pub images: bool, + /// Whether to send explicit `cache_control` breakpoints for prompt caching. + /// Leave disabled if the provider rejects requests containing them. + pub prompt_caching: bool, +} + +impl Default for AnthropicCompatibleModelCapabilities { + fn default() -> Self { + Self { + tools: true, + images: false, + prompt_caching: false, + } + } +} + #[with_fallible_options] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct AnthropicAvailableModel { diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index d623581ca1..f85966b49c 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -30,6 +30,10 @@ the difference between the Zed Agent, External Agents, and Terminal Threads. [Edit Prediction](./edit-prediction.md) has its own provider setup under `edit_predictions`. LLM providers on this page apply to model-backed Zed AI features such as Zed Agent, Inline Assistant, Git commit generation, and thread summaries. +## Anthropic-Compatible Providers {#anthropic-api-compatible} + +Anthropic-compatible provider setup has moved to [Use API Access](./use-api-access.md#anthropic-compatible). + ## OpenAI-Compatible Providers {#openai-api-compatible} OpenAI-compatible provider setup has moved to [Use API Access](./use-api-access.md#openai-compatible). diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index a6a3a18969..26610fd43c 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -27,6 +27,7 @@ Zed supports these first-class API providers for model-backed Zed AI features: - [Custom xAI Models](#xai-custom-models) - [OpenCode API](#opencode) - [Custom OpenCode Models](#opencode-custom-models) +- [Anthropic-compatible endpoints](#anthropic-compatible) - [OpenAI-compatible endpoints](#openai-compatible) ## What API Access Applies To {#support} @@ -413,6 +414,56 @@ The available configuration options for custom OpenCode models are: Custom OpenCode models are listed in the model dropdown in the Agent Panel. +### Anthropic-Compatible Endpoints {#anthropic-compatible} + +Use an Anthropic-compatible endpoint when a service implements Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) (`/v1/messages`) and gives you a custom base URL, model ID, and API key. + +You can add a custom Anthropic-compatible provider from Agent Settings with {#action agent::OpenSettings}. Look for `Add Provider` in the LLM Providers section, choose `Anthropic`, and fill in the provider name, API URL, model ID, and context window. + +You can also configure the provider in your settings file: + +```json [settings] +{ + "language_models": { + "anthropic_compatible": { + "Some Provider": { + "api_url": "https://api.someprovider.com", + "custom_headers": { + "X-Some-Header": "some-value" + }, + "available_models": [ + { + "name": "some-model", + "display_name": "Some Model", + "max_tokens": 200000, + "max_output_tokens": 32000, + "capabilities": { + "tools": true, + "images": false, + "prompt_caching": false + } + } + ] + } + } + } +} +``` + +By default, Anthropic-compatible models inherit these capabilities: + +- `tools`: `true` +- `images`: `false` +- `prompt_caching`: `false` + +Enable `prompt_caching` to send explicit `cache_control` breakpoints for [prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching); leave it disabled if the provider rejects requests containing them. + +The optional `custom_headers` map adds extra headers to every request, which some providers require. Headers managed by Zed (such as `X-Api-Key` and `Anthropic-Version`) cannot be overridden. + +Models also support the optional `default_temperature`, `extra_beta_headers` (sent as `anthropic-beta` headers), `mode`, and `tool_override` fields, which behave the same as in [Custom Anthropic Models](#anthropic-custom-models). + +Enter the API key in the provider settings UI or set the generated environment variable (`_API_KEY`; in the example above, `SOME_PROVIDER_API_KEY`). Do not put API keys in `settings.json`. + ### OpenAI-Compatible Endpoints {#openai-compatible} Use an OpenAI-compatible endpoint when you have a custom base URL, model ID, and API key.