From 0a53e4df8c0761447587b8981558ec8f4172d142 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Sat, 28 Feb 2026 14:46:58 +0800 Subject: [PATCH 01/11] language_models: Add Anthropic-compatible provider support in settings Signed-off-by: Xiaobo Liu --- assets/settings/default.json | 1 + crates/agent_ui/src/agent_configuration.rs | 69 ++- .../add_llm_provider_modal.rs | 365 ++++++++---- crates/language_models/src/language_models.rs | 58 ++ crates/language_models/src/provider.rs | 1 + .../src/provider/anthropic_compatible.rs | 554 ++++++++++++++++++ crates/language_models/src/settings.rs | 25 +- crates/settings_content/src/language_model.rs | 8 + 8 files changed, 931 insertions(+), 150 deletions(-) create mode 100644 crates/language_models/src/provider/anthropic_compatible.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 8f724f59b66486..7688babc4a563d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2200,6 +2200,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 9126d289c94563..f7d6166fda7cf4 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -386,13 +386,24 @@ impl AgentConfiguration { update_settings_file(fs.clone(), cx, { let provider_id = provider_id.clone(); move |settings, _| { + let key_to_remove = provider_id.0.as_ref(); + 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); + openai_compatible.remove(key_to_remove); + } + + if let Some(ref mut anthropic_compatible) = settings + .language_models + .as_mut() + .and_then(|language_models| { + language_models.anthropic_compatible.as_mut() + }) + { + anthropic_compatible.remove(key_to_remove); } } }); @@ -434,21 +445,37 @@ impl AgentConfiguration { let workspace = self.workspace.clone(); move |window, cx| { 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, { + let workspace = workspace.clone(); + move |window, cx| { + workspace + .update(cx, |workspace, cx| { + AddLlmProviderModal::toggle( + LlmCompatibleProvider::OpenAi, + workspace, + window, + cx, + ); + }) + .log_err(); + } + }) + .entry("Anthropic", None, { + let workspace = workspace.clone(); + move |window, cx| { + workspace + .update(cx, |workspace, cx| { + AddLlmProviderModal::toggle( + LlmCompatibleProvider::Anthropic, + workspace, + window, + cx, + ); + }) + .log_err(); + } + }) })) } }) @@ -1408,13 +1435,17 @@ 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()) } 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 a3a389ac0a068d..d673b6c6f9b78f 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 @@ -7,8 +7,14 @@ use gpui::{ DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, }; 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::{ + AnthropicAvailableModel, AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, + update_settings_file, +}; use ui::{ Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, @@ -40,18 +46,37 @@ 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 supports_chat_completions(&self) -> bool { + match self { + LlmCompatibleProvider::OpenAi => true, + LlmCompatibleProvider::Anthropic => false, } } } @@ -81,13 +106,14 @@ impl AddLlmProviderInput { provider_name, api_url, api_key, - models: vec![ModelInput::new(0, window, cx)], + models: vec![ModelInput::new(provider, 0, window, cx)], } } - fn add_model(&mut self, window: &mut Window, cx: &mut App) { + fn add_model(&mut self, provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) { let model_index = self.models.len(); - self.models.push(ModelInput::new(model_index, window, cx)); + self.models + .push(ModelInput::new(provider, model_index, window, cx)); } fn remove_model(&mut self, index: usize) { @@ -104,6 +130,7 @@ struct ModelCapabilityToggles { } struct ModelInput { + provider: LlmCompatibleProvider, name: Entity, max_completion_tokens: Entity, max_output_tokens: Entity, @@ -112,7 +139,12 @@ struct ModelInput { } impl ModelInput { - fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self { + fn new( + provider: LlmCompatibleProvider, + model_index: usize, + window: &mut Window, + cx: &mut App, + ) -> Self { let base_tab_index = (3 + (model_index * 4)) as isize; let model_name = single_line_input( @@ -148,15 +180,16 @@ impl ModelInput { cx, ); - let ModelCapabilities { + let OpenAiCompatibleModelCapabilities { tools, images, parallel_tool_calls, prompt_cache_key, chat_completions, - } = ModelCapabilities::default(); + } = OpenAiCompatibleModelCapabilities::default(); Self { + provider, name: model_name, max_completion_tokens, max_output_tokens, @@ -171,35 +204,46 @@ 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_u64_field( + &self, + 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"))) + } + + 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"))?, - capabilities: ModelCapabilities { + max_completion_tokens: Some(self.parse_u64_field( + &self.max_completion_tokens, + "Max Completion Tokens", + cx, + )?), + max_output_tokens: Some(self.parse_u64_field( + &self.max_output_tokens, + "Max Output Tokens", + cx, + )?), + max_tokens: self.parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, + capabilities: OpenAiCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), @@ -208,9 +252,31 @@ impl ModelInput { }, }) } + + fn parse_anthropic_compatible( + &self, + cx: &App, + ) -> Result { + Ok(AnthropicAvailableModel { + name: self.parse_name(cx)?, + display_name: None, + max_tokens: self.parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, + tool_override: None, + cache_configuration: None, + max_output_tokens: Some(self.parse_u64_field( + &self.max_output_tokens, + "Max Output Tokens", + cx, + )?), + default_temperature: None, + extra_beta_headers: Vec::new(), + mode: None, + }) + } } fn save_provider_to_settings( + provider: LlmCompatibleProvider, input: &AddLlmProviderInput, cx: &mut App, ) -> Task> { @@ -242,17 +308,31 @@ 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(); + let mut open_ai_models = Vec::new(); + let mut anthropic_models = Vec::new(); for model in &input.models { - match model.parse(cx) { - Ok(model) => { + match provider { + LlmCompatibleProvider::OpenAi => { + let model = match model.parse_open_ai_compatible(cx) { + Ok(model) => model, + Err(error) => return Task::ready(Err(error)), + }; if !model_names.insert(model.name.clone()) { return Task::ready(Err("Model Names must be unique".into())); } - models.push(model) + open_ai_models.push(model); + } + LlmCompatibleProvider::Anthropic => { + let model = match model.parse_anthropic_compatible(cx) { + Ok(model) => model, + Err(error) => return Task::ready(Err(error)), + }; + if !model_names.insert(model.name.clone()) { + return Task::ready(Err("Model Names must be unique".into())); + } + anthropic_models.push(model); } - Err(err) => return Task::ready(Err(err)), } } @@ -262,19 +342,34 @@ 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, - }, - ); + update_settings_file(fs, cx, move |settings, _cx| { + let language_models = settings.language_models.get_or_insert_default(); + match provider { + LlmCompatibleProvider::OpenAi => { + language_models + .openai_compatible + .get_or_insert_default() + .insert( + provider_name.clone(), + OpenAiCompatibleSettingsContent { + api_url: api_url.clone(), + available_models: open_ai_models.clone(), + }, + ); + } + LlmCompatibleProvider::Anthropic => { + language_models + .anthropic_compatible + .get_or_insert_default() + .insert( + provider_name.clone(), + AnthropicCompatibleSettingsContent { + api_url: api_url.clone(), + available_models: anthropic_models.clone(), + }, + ); + } + } }); }); Ok(()) @@ -310,7 +405,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 { @@ -346,7 +441,7 @@ impl AddLlmProviderModal { .icon_color(Color::Muted) .label_size(LabelSize::Small) .on_click(cx.listener(|this, _, window, cx| { - this.input.add_model(window, cx); + this.input.add_model(this.provider, window, cx); cx.notify(); })), ), @@ -376,73 +471,87 @@ impl AddLlmProviderModal { .child( h_flex() .gap_2() - .child(model.max_completion_tokens.clone()) + .when(model.provider.supports_chat_completions(), |parent| { + parent.child(model.max_completion_tokens.clone()) + }) .child(model.max_output_tokens.clone()), ) .child(model.max_tokens.clone()) - .child( - v_flex() - .gap_1() - .child( - Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) + .when(model.provider.supports_chat_completions(), |parent| { + parent.child( + v_flex() + .gap_1() + .child( + Checkbox::new( + ("supports-tools", ix), + model.capabilities.supports_tools, + ) .label("Supports tools") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_tools = *checked; - cx.notify(); - })), - ) - .child( - Checkbox::new(("supports-images", ix), model.capabilities.supports_images) + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_tools = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-images", ix), + model.capabilities.supports_images, + ) .label("Supports images") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_images = *checked; - cx.notify(); - })), - ) - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_images = *checked; + cx.notify(); + }, + )), ) - .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, + .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(); + }, + )), ) - .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, + .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(); + }, + )), ) - .label("Supports /chat/completions") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_chat_completions = - *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( Button::new(("remove-model", ix), "Remove Model") @@ -512,13 +621,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( @@ -719,7 +826,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let model_input = ModelInput::new(0, window, cx); + let model_input = ModelInput::new(LlmCompatibleProvider::OpenAi, 0, window, cx); model_input.name.update(cx, |input, cx| { input.set_text("somemodel", window, cx); }); @@ -744,7 +851,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); @@ -758,7 +865,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); + let mut model_input = ModelInput::new(LlmCompatibleProvider::OpenAi, 0, window, cx); model_input.name.update(cx, |input, cx| { input.set_text("somemodel", window, cx); }); @@ -769,7 +876,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); @@ -783,7 +890,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); + let mut model_input = ModelInput::new(LlmCompatibleProvider::OpenAi, 0, window, cx); model_input.name.update(cx, |input, cx| { input.set_text("somemodel", window, cx); }); @@ -794,7 +901,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); @@ -824,6 +931,7 @@ mod tests { cx } + #[cfg(test)] async fn save_provider_validation_errors( provider_name: &str, api_url: &str, @@ -847,7 +955,12 @@ mod tests { models.iter().enumerate() { if i >= input.models.len() { - input.models.push(ModelInput::new(i, window, cx)); + input.models.push(ModelInput::new( + LlmCompatibleProvider::OpenAi, + i, + window, + cx, + )); } let model = &mut input.models[i]; set_text(&model.name, name, window, cx); @@ -860,7 +973,7 @@ mod tests { ); set_text(&model.max_output_tokens, max_output_tokens, window, cx); } - save_provider_to_settings(&input, cx) + save_provider_to_settings(LlmCompatibleProvider::OpenAi, &input, cx) }); task.await.err() diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index f22ea00c9e801e..904783d829e6bb 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -14,6 +14,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; @@ -90,6 +91,11 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .keys() .cloned() .collect::>(); + let mut anthropic_compatible_providers = AllLanguageModelSettings::get_global(cx) + .anthropic_compatible + .keys() + .cloned() + .collect::>(); registry.update(cx, |registry, cx| { register_openai_compatible_providers( @@ -99,6 +105,13 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { client.clone(), cx, ); + register_anthropic_compatible_providers( + registry, + &HashSet::default(), + &anthropic_compatible_providers, + client.clone(), + cx, + ); }); cx.observe_global::(move |cx| { let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) @@ -118,6 +131,24 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { }); openai_compatible_providers = openai_compatible_providers_new; } + + let anthropic_compatible_providers_new = AllLanguageModelSettings::get_global(cx) + .anthropic_compatible + .keys() + .cloned() + .collect::>(); + if anthropic_compatible_providers_new != anthropic_compatible_providers { + registry.update(cx, |registry, cx| { + register_anthropic_compatible_providers( + registry, + &anthropic_compatible_providers, + &anthropic_compatible_providers_new, + client.clone(), + cx, + ); + }); + anthropic_compatible_providers = anthropic_compatible_providers_new; + } }) .detach(); } @@ -149,6 +180,33 @@ fn register_openai_compatible_providers( } } +fn register_anthropic_compatible_providers( + registry: &mut LanguageModelRegistry, + old: &HashSet>, + new: &HashSet>, + client: Arc, + cx: &mut Context, +) { + for provider_id in old { + if !new.contains(provider_id) { + registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); + } + } + + for provider_id in new { + if !old.contains(provider_id) { + registry.register_provider( + Arc::new(AnthropicCompatibleLanguageModelProvider::new( + provider_id.clone(), + client.http_client(), + cx, + )), + cx, + ); + } + } +} + fn register_language_model_providers( registry: &mut LanguageModelRegistry, user_store: Entity, diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index 27f43e37f5be34..77921a8611f981 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -1,4 +1,5 @@ pub mod anthropic; +pub mod anthropic_compatible; pub mod bedrock; pub mod cloud; pub mod copilot_chat; 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 00000000000000..2135ac0c86c455 --- /dev/null +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -0,0 +1,554 @@ +use anthropic::{AnthropicError, AnthropicModelMode}; +use anyhow::Result; +use convert_case::{Case, Casing}; +use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use http_client::HttpClient; +use language_model::{ + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, RateLimiter, +}; +use menu; +use settings::{Settings, SettingsStore}; +use std::sync::Arc; +use ui::{ElevationIndex, Tooltip, prelude::*}; +use ui_input::InputField; +use util::ResultExt; + +use crate::provider::anthropic::{ + AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic, + into_anthropic_count_tokens_request, +}; + +pub use settings::AnthropicAvailableModel as AvailableModel; + +#[derive(Default, Clone, Debug, PartialEq)] +pub struct AnthropicCompatibleSettings { + pub api_url: String, + pub available_models: Vec, +} + +pub struct AnthropicCompatibleLanguageModelProvider { + id: LanguageModelProviderId, + name: LanguageModelProviderName, + http_client: Arc, + state: Entity, +} + +pub struct State { + id: Arc, + api_key_state: ApiKeyState, + settings: AnthropicCompatibleSettings, +} + +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 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, cx) + } + + fn authenticate(&mut self, cx: &mut Context) -> Task> { + 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, cx) + } +} + +impl AnthropicCompatibleLanguageModelProvider { + pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { + fn resolve_settings<'a>( + id: &'a str, + cx: &'a App, + ) -> Option<&'a AnthropicCompatibleSettings> { + crate::AllLanguageModelSettings::get_global(cx) + .anthropic_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 api_url = SharedString::new(settings.api_url.as_str()); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + 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, + } + }); + + Self { + id: id.clone().into(), + name: id.into(), + http_client, + state, + } + } + + fn create_language_model(&self, model: AvailableModel) -> Arc { + let model = anthropic::Model::Custom { + name: model.name.clone(), + display_name: model.display_name.clone(), + max_tokens: model.max_tokens, + tool_override: model.tool_override.clone(), + cache_configuration: model.cache_configuration.as_ref().map(|configuration| { + anthropic::AnthropicModelCacheConfiguration { + max_cache_anchors: configuration.max_cache_anchors, + should_speculate: configuration.should_speculate, + min_total_token: configuration.min_total_token, + } + }), + max_output_tokens: model.max_output_tokens, + default_temperature: model.default_temperature, + extra_beta_headers: model.extra_beta_headers.clone(), + mode: model.mode.unwrap_or_default().into(), + }; + + Arc::new(AnthropicCompatibleLanguageModel { + id: LanguageModelId::from(model.id().to_string()), + provider_id: self.id.clone(), + provider_name: self.name.clone(), + model, + 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::AiAnthropic) + } + + 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| ConfigurationView::new(self.state.clone(), 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, + 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) = self.state.read_with(cx, |state, _cx| { + let api_url = state.settings.api_url.clone(); + (state.api_key_state.key(&api_url), api_url) + }); + + 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, + ); + + request.await.map_err(Into::into) + } + .boxed() + } +} + +impl LanguageModel for AnthropicCompatibleLanguageModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name().to_string()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + self.provider_id.clone() + } + + fn provider_name(&self) -> LanguageModelProviderName { + self.provider_name.clone() + } + + fn supports_tools(&self) -> bool { + true + } + + fn supports_images(&self) -> bool { + true + } + + fn supports_streaming_tools(&self) -> bool { + true + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto + | LanguageModelToolChoice::Any + | LanguageModelToolChoice::None => true, + } + } + + fn supports_thinking(&self) -> bool { + matches!(self.model.mode(), AnthropicModelMode::Thinking { .. }) + } + + fn telemetry_id(&self) -> String { + format!("anthropic/{}", self.model.id()) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn max_output_tokens(&self) -> Option { + Some(self.model.max_output_tokens()) + } + + fn count_tokens( + &self, + request: LanguageModelRequest, + cx: &App, + ) -> BoxFuture<'static, Result> { + let http_client = self.http_client.clone(); + let model_id = self.model.request_id().to_string(); + let mode = self.model.mode(); + + let (api_key, api_url) = self.state.read_with(cx, |state, _cx| { + let api_url = state.settings.api_url.clone(); + ( + state.api_key_state.key(&api_url).map(|key| key.to_string()), + api_url, + ) + }); + + async move { + let Some(api_key) = api_key else { + return count_anthropic_tokens_with_tiktoken(request); + }; + + let count_request = + into_anthropic_count_tokens_request(request.clone(), model_id, mode); + + match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request) + .await + { + Ok(response) => Ok(response.input_tokens), + Err(error) => { + log::error!( + "Anthropic-compatible count_tokens API failed, falling back to tiktoken: {error:?}" + ); + count_anthropic_tokens_with_tiktoken(request) + } + } + } + .boxed() + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let request = into_anthropic( + request, + self.model.request_id().into(), + self.model.default_temperature(), + self.model.max_output_tokens(), + self.model.mode(), + ); + let completion_request = self.stream_completion(request, cx); + let future = self.request_limiter.stream(async move { + let response = completion_request.await?; + Ok(AnthropicEventMapper::new().map_stream(response)) + }); + async move { Ok(future.await?.boxed()) }.boxed() + } + + fn cache_configuration(&self) -> Option { + self.model + .cache_configuration() + .map(|configuration| LanguageModelCacheConfiguration { + max_cache_anchors: configuration.max_cache_anchors, + should_speculate: configuration.should_speculate, + min_total_token: configuration.min_total_token, + }) + } +} + +struct ConfigurationView { + api_key_editor: Entity, + state: Entity, + load_credentials_task: Option>, +} + +impl ConfigurationView { + const PLACEHOLDER_TEXT: &'static str = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { + let api_key_editor = cx.new(|cx| InputField::new(window, cx, Self::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| { + if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { + match task.await { + Ok(()) | Err(AuthenticateError::CredentialsNotFound) => {} + Err(error) => { + log::error!( + "Failed to load Anthropic-compatible provider API credentials: {error}" + ); + } + } + } + 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; + } + + 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 Anthropic-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) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .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/settings.rs b/crates/language_models/src/settings.rs index 7466a337f636ab..e61813b1902129 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -4,16 +4,18 @@ use collections::HashMap; use settings::RegisterSetting; use crate::provider::{ - anthropic::AnthropicSettings, bedrock::AmazonBedrockSettings, cloud::ZedDotDevSettings, - deepseek::DeepSeekSettings, google::GoogleSettings, lmstudio::LmStudioSettings, - mistral::MistralSettings, ollama::OllamaSettings, open_ai::OpenAiSettings, - open_ai_compatible::OpenAiCompatibleSettings, open_router::OpenRouterSettings, - vercel::VercelSettings, vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings, + anthropic::AnthropicSettings, anthropic_compatible::AnthropicCompatibleSettings, + bedrock::AmazonBedrockSettings, cloud::ZedDotDevSettings, deepseek::DeepSeekSettings, + google::GoogleSettings, lmstudio::LmStudioSettings, mistral::MistralSettings, + ollama::OllamaSettings, open_ai::OpenAiSettings, open_ai_compatible::OpenAiCompatibleSettings, + open_router::OpenRouterSettings, vercel::VercelSettings, + 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, @@ -35,6 +37,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(); @@ -53,6 +56,18 @@ impl settings::Settings for AllLanguageModelSettings { api_url: anthropic.api_url.unwrap(), available_models: anthropic.available_models.unwrap_or_default(), }, + anthropic_compatible: anthropic_compatible + .into_iter() + .map(|(key, value)| { + ( + key, + AnthropicCompatibleSettings { + api_url: value.api_url, + available_models: value.available_models, + }, + ) + }) + .collect(), bedrock: AmazonBedrockSettings { available_models: bedrock.available_models.unwrap_or_default(), region: bedrock.region, diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 6af419119d8199..ec755f8e87b2fb 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -10,6 +10,7 @@ use std::sync::Arc; #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct AllLanguageModelSettingsContent { pub anthropic: Option, + pub anthropic_compatible: Option, AnthropicCompatibleSettingsContent>>, pub bedrock: Option, pub deepseek: Option, pub google: Option, @@ -33,6 +34,13 @@ pub struct AnthropicSettingsContent { pub available_models: Option>, } +#[with_fallible_options] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] +pub struct AnthropicCompatibleSettingsContent { + pub api_url: String, + pub available_models: Vec, +} + #[with_fallible_options] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct AnthropicAvailableModel { From 2709530fdc5589c0ef0eb981c421624c7ef0a4f3 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Tue, 9 Jun 2026 20:46:17 -0700 Subject: [PATCH 02/11] align code with existing openai compat code patterns --- crates/agent_ui/src/agent_configuration.rs | 46 +- .../add_llm_provider_modal.rs | 440 ++++++++++-------- crates/language_model/src/language_model.rs | 27 +- crates/language_models/src/language_models.rs | 64 +-- .../src/provider/anthropic_compatible.rs | 82 ++-- crates/settings_content/src/language_model.rs | 42 +- docs/src/ai/llm-providers.md | 49 ++ 7 files changed, 416 insertions(+), 334 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index f7d6166fda7cf4..2fcf58a291eba7 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -444,38 +444,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(); - } - }) - .entry("Anthropic", None, { - let workspace = workspace.clone(); - move |window, cx| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle( - LlmCompatibleProvider::Anthropic, - workspace, - window, - cx, - ); - }) - .log_err(); - } - }) + .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi)) + .entry( + "Anthropic", + None, + open_modal(LlmCompatibleProvider::Anthropic), + ) })) } }) 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 d673b6c6f9b78f..9c16ac09d1711f 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 @@ -12,8 +12,8 @@ use language_models::provider::open_ai_compatible::{ ModelCapabilities as OpenAiCompatibleModelCapabilities, }; use settings::{ - AnthropicAvailableModel, AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, - update_settings_file, + AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, + AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, update_settings_file, }; use ui::{ Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, @@ -73,11 +73,8 @@ impl LlmCompatibleProvider { } } - fn supports_chat_completions(&self) -> bool { - match self { - LlmCompatibleProvider::OpenAi => true, - LlmCompatibleProvider::Anthropic => false, - } + fn is_open_ai(&self) -> bool { + matches!(self, LlmCompatibleProvider::OpenAi) } } @@ -106,14 +103,13 @@ impl AddLlmProviderInput { provider_name, api_url, api_key, - models: vec![ModelInput::new(provider, 0, window, cx)], + models: vec![ModelInput::new(0, window, cx)], } } - fn add_model(&mut self, provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) { + fn add_model(&mut self, window: &mut Window, cx: &mut App) { let model_index = self.models.len(); - self.models - .push(ModelInput::new(provider, model_index, window, cx)); + self.models.push(ModelInput::new(model_index, window, cx)); } fn remove_model(&mut self, index: usize) { @@ -130,7 +126,6 @@ struct ModelCapabilityToggles { } struct ModelInput { - provider: LlmCompatibleProvider, name: Entity, max_completion_tokens: Entity, max_output_tokens: Entity, @@ -139,12 +134,7 @@ struct ModelInput { } impl ModelInput { - fn new( - provider: LlmCompatibleProvider, - model_index: usize, - window: &mut Window, - cx: &mut App, - ) -> Self { + fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self { let base_tab_index = (3 + (model_index * 4)) as isize; let model_name = single_line_input( @@ -189,7 +179,6 @@ impl ModelInput { } = OpenAiCompatibleModelCapabilities::default(); Self { - provider, name: model_name, max_completion_tokens, max_output_tokens, @@ -256,8 +245,8 @@ impl ModelInput { fn parse_anthropic_compatible( &self, cx: &App, - ) -> Result { - Ok(AnthropicAvailableModel { + ) -> Result { + Ok(AnthropicCompatibleAvailableModel { name: self.parse_name(cx)?, display_name: None, max_tokens: self.parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, @@ -271,10 +260,19 @@ impl ModelInput { default_temperature: None, extra_beta_headers: Vec::new(), mode: None, + capabilities: AnthropicCompatibleModelCapabilities { + tools: self.capabilities.supports_tools.selected(), + images: self.capabilities.supports_images.selected(), + }, }) } } +enum ParsedModels { + OpenAi(Vec), + Anthropic(Vec), +} + fn save_provider_to_settings( provider: LlmCompatibleProvider, input: &AddLlmProviderInput, @@ -309,33 +307,35 @@ fn save_provider_to_settings( } let mut model_names: HashSet = HashSet::default(); - let mut open_ai_models = Vec::new(); - let mut anthropic_models = Vec::new(); for model in &input.models { - match provider { - LlmCompatibleProvider::OpenAi => { - let model = match model.parse_open_ai_compatible(cx) { - Ok(model) => model, - Err(error) => return Task::ready(Err(error)), - }; - if !model_names.insert(model.name.clone()) { - return Task::ready(Err("Model Names must be unique".into())); - } - open_ai_models.push(model); - } - LlmCompatibleProvider::Anthropic => { - let model = match model.parse_anthropic_compatible(cx) { - Ok(model) => model, - Err(error) => return Task::ready(Err(error)), - }; - if !model_names.insert(model.name.clone()) { - return Task::ready(Err("Model Names must be unique".into())); - } - anthropic_models.push(model); - } + let name = match model.parse_name(cx) { + Ok(name) => name, + Err(error) => return Task::ready(Err(error)), + }; + if !model_names.insert(name) { + return Task::ready(Err("Model Names must be unique".into())); } } + 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)), + }; + let fs = ::global(cx); let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes()); cx.spawn(async move |cx| { @@ -344,28 +344,28 @@ fn save_provider_to_settings( cx.update(|cx| { update_settings_file(fs, cx, move |settings, _cx| { let language_models = settings.language_models.get_or_insert_default(); - match provider { - LlmCompatibleProvider::OpenAi => { + match models { + ParsedModels::OpenAi(available_models) => { language_models .openai_compatible .get_or_insert_default() .insert( - provider_name.clone(), + provider_name, OpenAiCompatibleSettingsContent { - api_url: api_url.clone(), - available_models: open_ai_models.clone(), + api_url, + available_models, }, ); } - LlmCompatibleProvider::Anthropic => { + ParsedModels::Anthropic(available_models) => { language_models .anthropic_compatible .get_or_insert_default() .insert( - provider_name.clone(), + provider_name, AnthropicCompatibleSettingsContent { - api_url: api_url.clone(), - available_models: anthropic_models.clone(), + api_url, + available_models, }, ); } @@ -441,7 +441,7 @@ impl AddLlmProviderModal { .icon_color(Color::Muted) .label_size(LabelSize::Small) .on_click(cx.listener(|this, _, window, cx| { - this.input.add_model(this.provider, window, cx); + this.input.add_model(window, cx); cx.notify(); })), ), @@ -457,6 +457,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() @@ -471,87 +472,80 @@ impl AddLlmProviderModal { .child( h_flex() .gap_2() - .when(model.provider.supports_chat_completions(), |parent| { + .when(is_open_ai, |parent| { parent.child(model.max_completion_tokens.clone()) }) .child(model.max_output_tokens.clone()), ) .child(model.max_tokens.clone()) - .when(model.provider.supports_chat_completions(), |parent| { - parent.child( - v_flex() - .gap_1() - .child( - Checkbox::new( - ("supports-tools", ix), - model.capabilities.supports_tools, - ) + .child( + v_flex() + .gap_1() + .child( + Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) .label("Supports tools") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_tools = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-images", ix), - model.capabilities.supports_images, - ) + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_tools = *checked; + cx.notify(); + })), + ) + .child( + Checkbox::new(("supports-images", ix), model.capabilities.supports_images) .label("Supports images") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_images = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_images = *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(); + }, + )), ) - .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, + .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(); + }, + )), ) - .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, + .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(); + }, + )), ) - .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( Button::new(("remove-model", ix), "Remove Model") @@ -713,82 +707,110 @@ 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( + provider, + "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( + provider, + "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", + "somekey", + vec![("somemodel", "abc", "200000", "32000")], + cx, + ) + .await, + Some("Max Tokens must be a number".into()) + ); - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "abc", "32000")], - cx, - ) - .await, - Some("Max Completion 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( - "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", - vec![ - ("somemodel", "200000", "200000", "32000"), - ("somemodel", "200000", "200000", "32000"), - ], + vec![("somemodel", "200000", "abc", "32000")], cx, ) .await, - Some("Model Names must be unique".into()) + Some("Max Completion Tokens must be a number".into()) ); } @@ -810,6 +832,7 @@ mod tests { assert_eq!( save_provider_validation_errors( + LlmCompatibleProvider::OpenAi, "someprovider", "someurl", "someapikey", @@ -826,7 +849,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let model_input = ModelInput::new(LlmCompatibleProvider::OpenAi, 0, window, cx); + let model_input = ModelInput::new(0, window, cx); model_input.name.update(cx, |input, cx| { input.set_text("somemodel", window, cx); }); @@ -865,7 +888,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let mut model_input = ModelInput::new(LlmCompatibleProvider::OpenAi, 0, window, cx); + let mut model_input = ModelInput::new(0, window, cx); model_input.name.update(cx, |input, cx| { input.set_text("somemodel", window, cx); }); @@ -890,7 +913,7 @@ mod tests { let cx = setup_test(cx).await; cx.update(|window, cx| { - let mut model_input = ModelInput::new(LlmCompatibleProvider::OpenAi, 0, window, cx); + let mut model_input = ModelInput::new(0, window, cx); model_input.name.update(cx, |input, cx| { input.set_text("somemodel", window, cx); }); @@ -911,6 +934,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); @@ -931,8 +980,8 @@ mod tests { cx } - #[cfg(test)] async fn save_provider_validation_errors( + provider: LlmCompatibleProvider, provider_name: &str, api_url: &str, api_key: &str, @@ -946,7 +995,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); @@ -955,12 +1004,7 @@ mod tests { models.iter().enumerate() { if i >= input.models.len() { - input.models.push(ModelInput::new( - LlmCompatibleProvider::OpenAi, - i, - window, - cx, - )); + input.models.push(ModelInput::new(i, window, cx)); } let model = &mut input.models[i]; set_text(&model.name, name, window, cx); @@ -973,7 +1017,7 @@ mod tests { ); set_text(&model.max_output_tokens, max_output_tokens, window, cx); } - save_provider_to_settings(LlmCompatibleProvider::OpenAi, &input, cx) + save_provider_to_settings(provider, &input, cx) }); task.await.err() diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index c403774499c9dc..7e22e1caf28d8a 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -309,9 +309,8 @@ impl LanguageModelCompletionError { } } -impl From for LanguageModelCompletionError { - fn from(error: AnthropicError) -> Self { - let provider = ANTHROPIC_PROVIDER_NAME; +impl LanguageModelCompletionError { + pub fn from_anthropic(error: AnthropicError, provider: LanguageModelProviderName) -> Self { match error { AnthropicError::SerializeRequest(error) => Self::SerializeRequest { provider, error }, AnthropicError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error }, @@ -336,15 +335,15 @@ impl From for LanguageModelCompletionError { provider, retry_after, }, - AnthropicError::ApiError(api_error) => api_error.into(), + AnthropicError::ApiError(api_error) => Self::from_anthropic_api(api_error, provider), } } -} -impl From for LanguageModelCompletionError { - fn from(error: anthropic::ApiError) -> Self { + pub fn from_anthropic_api( + error: anthropic::ApiError, + provider: LanguageModelProviderName, + ) -> Self { use anthropic::ApiErrorCode::*; - let provider = ANTHROPIC_PROVIDER_NAME; match error.code() { Some(code) => match code { InvalidRequestError => Self::BadRequestFormat { @@ -381,6 +380,18 @@ impl From for LanguageModelCompletionError { } } +impl From for LanguageModelCompletionError { + fn from(error: AnthropicError) -> Self { + Self::from_anthropic(error, ANTHROPIC_PROVIDER_NAME) + } +} + +impl From for LanguageModelCompletionError { + fn from(error: anthropic::ApiError) -> Self { + Self::from_anthropic_api(error, ANTHROPIC_PROVIDER_NAME) + } +} + impl From for LanguageModelCompletionError { fn from(error: open_ai::RequestError) -> Self { match error { diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 904783d829e6bb..6caeb948e16c5a 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -4,7 +4,11 @@ use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; use gpui::{App, Context, Entity}; -use language_model::{LanguageModelProviderId, LanguageModelRegistry}; +use http_client::HttpClient; +use language_model::{ + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderState, + LanguageModelRegistry, +}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod extension; @@ -98,18 +102,20 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .collect::>(); registry.update(cx, |registry, cx| { - register_openai_compatible_providers( + register_compatible_providers( registry, &HashSet::default(), &openai_compatible_providers, - client.clone(), + &client, + OpenAiCompatibleLanguageModelProvider::new, cx, ); - register_anthropic_compatible_providers( + register_compatible_providers( registry, &HashSet::default(), &anthropic_compatible_providers, - client.clone(), + &client, + AnthropicCompatibleLanguageModelProvider::new, cx, ); }); @@ -121,11 +127,12 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .collect::>(); if openai_compatible_providers_new != openai_compatible_providers { registry.update(cx, |registry, cx| { - register_openai_compatible_providers( + register_compatible_providers( registry, &openai_compatible_providers, &openai_compatible_providers_new, - client.clone(), + &client, + OpenAiCompatibleLanguageModelProvider::new, cx, ); }); @@ -139,11 +146,12 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .collect::>(); if anthropic_compatible_providers_new != anthropic_compatible_providers { registry.update(cx, |registry, cx| { - register_anthropic_compatible_providers( + register_compatible_providers( registry, &anthropic_compatible_providers, &anthropic_compatible_providers_new, - client.clone(), + &client, + AnthropicCompatibleLanguageModelProvider::new, cx, ); }); @@ -153,38 +161,12 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .detach(); } -fn register_openai_compatible_providers( - registry: &mut LanguageModelRegistry, - old: &HashSet>, - new: &HashSet>, - client: Arc, - cx: &mut Context, -) { - for provider_id in old { - if !new.contains(provider_id) { - 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(), - cx, - )), - cx, - ); - } - } -} - -fn register_anthropic_compatible_providers( +fn register_compatible_providers( registry: &mut LanguageModelRegistry, old: &HashSet>, new: &HashSet>, - client: Arc, + client: &Arc, + new_provider: fn(Arc, Arc, &mut App) -> T, cx: &mut Context, ) { for provider_id in old { @@ -196,11 +178,7 @@ fn register_anthropic_compatible_providers( for provider_id in new { if !old.contains(provider_id) { registry.register_provider( - Arc::new(AnthropicCompatibleLanguageModelProvider::new( - provider_id.clone(), - client.http_client(), - cx, - )), + Arc::new(new_provider(provider_id.clone(), client.http_client(), cx)), cx, ); } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 2135ac0c86c455..0ada657d86149b 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -20,10 +20,10 @@ use util::ResultExt; use crate::provider::anthropic::{ AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic, - into_anthropic_count_tokens_request, }; -pub use settings::AnthropicAvailableModel as AvailableModel; +pub use settings::AnthropicCompatibleAvailableModel as AvailableModel; +pub use settings::AnthropicCompatibleModelCapabilities as ModelCapabilities; #[derive(Default, Clone, Debug, PartialEq)] pub struct AnthropicCompatibleSettings { @@ -112,11 +112,12 @@ impl AnthropicCompatibleLanguageModelProvider { } fn create_language_model(&self, model: AvailableModel) -> Arc { + let capabilities = model.capabilities.clone(); let model = anthropic::Model::Custom { - name: model.name.clone(), - display_name: model.display_name.clone(), + name: model.name, + display_name: model.display_name, max_tokens: model.max_tokens, - tool_override: model.tool_override.clone(), + tool_override: model.tool_override, cache_configuration: model.cache_configuration.as_ref().map(|configuration| { anthropic::AnthropicModelCacheConfiguration { max_cache_anchors: configuration.max_cache_anchors, @@ -126,7 +127,7 @@ impl AnthropicCompatibleLanguageModelProvider { }), max_output_tokens: model.max_output_tokens, default_temperature: model.default_temperature, - extra_beta_headers: model.extra_beta_headers.clone(), + extra_beta_headers: model.extra_beta_headers, mode: model.mode.unwrap_or_default().into(), }; @@ -135,6 +136,7 @@ impl AnthropicCompatibleLanguageModelProvider { provider_id: self.id.clone(), provider_name: self.name.clone(), model, + capabilities, state: self.state.clone(), http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), @@ -215,6 +217,7 @@ pub struct AnthropicCompatibleLanguageModel { provider_id: LanguageModelProviderId, provider_name: LanguageModelProviderName, model: anthropic::Model, + capabilities: ModelCapabilities, state: Entity, http_client: Arc, request_limiter: RateLimiter, @@ -257,7 +260,9 @@ impl AnthropicCompatibleLanguageModel { beta_headers, ); - request.await.map_err(Into::into) + request + .await + .map_err(|error| LanguageModelCompletionError::from_anthropic(error, provider_name)) } .boxed() } @@ -281,22 +286,21 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { } fn supports_tools(&self) -> bool { - true + self.capabilities.tools } fn supports_images(&self) -> bool { - true + self.capabilities.images } fn supports_streaming_tools(&self) -> bool { - true + self.capabilities.tools } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { - LanguageModelToolChoice::Auto - | LanguageModelToolChoice::Any - | LanguageModelToolChoice::None => true, + LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => self.capabilities.tools, + LanguageModelToolChoice::None => true, } } @@ -321,39 +325,10 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { request: LanguageModelRequest, cx: &App, ) -> BoxFuture<'static, Result> { - let http_client = self.http_client.clone(); - let model_id = self.model.request_id().to_string(); - let mode = self.model.mode(); - - let (api_key, api_url) = self.state.read_with(cx, |state, _cx| { - let api_url = state.settings.api_url.clone(); - ( - state.api_key_state.key(&api_url).map(|key| key.to_string()), - api_url, - ) - }); - - async move { - let Some(api_key) = api_key else { - return count_anthropic_tokens_with_tiktoken(request); - }; - - let count_request = - into_anthropic_count_tokens_request(request.clone(), model_id, mode); - - match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request) - .await - { - Ok(response) => Ok(response.input_tokens), - Err(error) => { - log::error!( - "Anthropic-compatible count_tokens API failed, falling back to tiktoken: {error:?}" - ); - count_anthropic_tokens_with_tiktoken(request) - } - } - } - .boxed() + // Unlike the first-party Anthropic provider, we don't call the count_tokens API here, + // since compatible providers may not implement it. Estimate locally instead. + cx.background_spawn(async move { count_anthropic_tokens_with_tiktoken(request) }) + .boxed() } fn stream_completion( @@ -413,14 +388,13 @@ impl ConfigurationView { 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))) { - match task.await { - Ok(()) | Err(AuthenticateError::CredentialsNotFound) => {} - Err(error) => { - log::error!( - "Failed to load Anthropic-compatible provider API credentials: {error}" - ); - } + let task = state.update(cx, |state, cx| state.authenticate(cx)); + match task.await { + Ok(()) | Err(AuthenticateError::CredentialsNotFound) => {} + Err(error) => { + log::error!( + "Failed to load Anthropic-compatible provider API credentials: {error}" + ); } } this.update(cx, |this, cx| { diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index ec755f8e87b2fb..fbb3805c3831f3 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -38,7 +38,47 @@ pub struct AnthropicSettingsContent { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct AnthropicCompatibleSettingsContent { pub api_url: String, - pub available_models: Vec, + pub available_models: Vec, +} + +#[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, + /// Configuration of the Anthropic-style prompt caching API. + pub cache_configuration: 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, +} + +impl Default for AnthropicCompatibleModelCapabilities { + fn default() -> Self { + Self { + tools: true, + images: false, + } + } } #[with_fallible_options] diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3a32bd96e73d9d..222ae37d756288 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -23,6 +23,7 @@ Zed supports these providers with your own API keys: - [Amazon Bedrock](#amazon-bedrock) - [Anthropic](#anthropic) +- [Anthropic API Compatible](#anthropic-api-compatible) - [DeepSeek](#deepseek) - [GitHub Copilot Chat](#github-copilot-chat) - [Google AI](#google-ai) @@ -233,6 +234,54 @@ You can configure a model to use [extended thinking](https://docs.anthropic.com/ } ``` +### Anthropic API Compatible {#anthropic-api-compatible} + +Zed supports using Anthropic compatible APIs by specifying a custom `api_url` and `available_models` for the Anthropic provider. +This is useful for connecting to other hosted services that implement Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) (`/v1/messages`). + +You can add a custom, Anthropic-compatible model either via the UI or by editing your settings file. + +To do it via the UI, go to the Agent Panel settings (`agent: open settings`) and look for the "Add Provider" button to the right of the "LLM Providers" section title. +Then, choose "Anthropic" and fill up the input fields available in the modal. + +To do it via your settings file ([how to edit](../configuring-zed.md#settings-files)), add the following snippet under `language_models`: + +```json [settings] +{ + "language_models": { + "anthropic_compatible": { + "Some Provider": { + "api_url": "https://api.someprovider.com", + "available_models": [ + { + "name": "some-model", + "display_name": "Some Model", + "max_tokens": 200000, + "max_output_tokens": 32000, + "capabilities": { + "tools": true, + "images": false + } + } + ] + } + } + } +} +``` + +By default, Anthropic-compatible models inherit the following capabilities: + +- `tools`: true (supports tool/function calling) +- `images`: false (does not support image inputs) + +Models also support the optional `default_temperature`, `extra_beta_headers` (sent as `anthropic-beta` headers), `mode`, `cache_configuration`, and `tool_override` fields, which behave the same as in the [Anthropic provider's custom models](#anthropic-custom-models). + +Token counts for Anthropic-compatible models are estimated locally rather than fetched from the provider's API. + +Note that LLM API keys aren't stored in your settings file. +So, ensure you have it set in your environment variables (`_API_KEY=`) so your settings can pick it up. In the example above, it would be `SOME_PROVIDER_API_KEY=`. + ### DeepSeek {#deepseek} 1. Visit the DeepSeek platform and [create an API key](https://platform.deepseek.com/api_keys) From d70dba5fe84a12665ce84fd6f537dfd77b78c2a0 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Tue, 9 Jun 2026 20:46:17 -0700 Subject: [PATCH 03/11] Refactor provider event mapping with provider name --- crates/language_model/src/language_model.rs | 6 +- crates/language_models/src/language_models.rs | 123 ++++++--- .../language_models/src/provider/anthropic.rs | 16 +- .../src/provider/anthropic_compatible.rs | 240 +++--------------- crates/language_models/src/provider/cloud.rs | 2 +- .../src/provider/open_ai_compatible.rs | 229 ++--------------- crates/language_models/src/provider/util.rs | 230 ++++++++++++++++- 7 files changed, 378 insertions(+), 468 deletions(-) diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 7e22e1caf28d8a..feabe1e314891a 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -322,11 +322,7 @@ impl LanguageModelCompletionError { AnthropicError::HttpResponseError { status_code, message, - } => Self::HttpResponseError { - provider, - status_code, - message, - }, + } => Self::from_http_status(provider, status_code, message, None), AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded { provider, retry_after: Some(retry_after), diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 6caeb948e16c5a..30c4198cebec86 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -90,99 +90,140 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { }); } - let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx) + let mut openai_compatible_provider_settings = AllLanguageModelSettings::get_global(cx) .openai_compatible .keys() .cloned() .collect::>(); - let mut anthropic_compatible_providers = AllLanguageModelSettings::get_global(cx) + let mut anthropic_compatible_provider_settings = AllLanguageModelSettings::get_global(cx) .anthropic_compatible .keys() .cloned() .collect::>(); + let mut registered_openai_compatible_providers = HashSet::default(); + let mut registered_anthropic_compatible_providers = HashSet::default(); registry.update(cx, |registry, cx| { - register_compatible_providers( + registered_openai_compatible_providers = register_new_compatible_providers( registry, - &HashSet::default(), - &openai_compatible_providers, + std::mem::take(&mut registered_openai_compatible_providers), + &openai_compatible_provider_settings, &client, + "OpenAI", OpenAiCompatibleLanguageModelProvider::new, cx, ); - register_compatible_providers( + registered_anthropic_compatible_providers = register_new_compatible_providers( registry, - &HashSet::default(), - &anthropic_compatible_providers, + std::mem::take(&mut registered_anthropic_compatible_providers), + &anthropic_compatible_provider_settings, &client, + "Anthropic", AnthropicCompatibleLanguageModelProvider::new, cx, ); }); cx.observe_global::(move |cx| { - let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) + let openai_compatible_provider_settings_new = AllLanguageModelSettings::get_global(cx) .openai_compatible .keys() .cloned() .collect::>(); - if openai_compatible_providers_new != openai_compatible_providers { + let anthropic_compatible_provider_settings_new = AllLanguageModelSettings::get_global(cx) + .anthropic_compatible + .keys() + .cloned() + .collect::>(); + + if openai_compatible_provider_settings_new != openai_compatible_provider_settings + || anthropic_compatible_provider_settings_new != anthropic_compatible_provider_settings + { registry.update(cx, |registry, cx| { - register_compatible_providers( + registered_openai_compatible_providers = unregister_removed_compatible_providers( registry, - &openai_compatible_providers, - &openai_compatible_providers_new, + ®istered_openai_compatible_providers, + &openai_compatible_provider_settings_new, + cx, + ); + registered_anthropic_compatible_providers = unregister_removed_compatible_providers( + registry, + ®istered_anthropic_compatible_providers, + &anthropic_compatible_provider_settings_new, + cx, + ); + + registered_openai_compatible_providers = register_new_compatible_providers( + registry, + std::mem::take(&mut registered_openai_compatible_providers), + &openai_compatible_provider_settings_new, &client, + "OpenAI", OpenAiCompatibleLanguageModelProvider::new, cx, ); - }); - openai_compatible_providers = openai_compatible_providers_new; - } - - let anthropic_compatible_providers_new = AllLanguageModelSettings::get_global(cx) - .anthropic_compatible - .keys() - .cloned() - .collect::>(); - if anthropic_compatible_providers_new != anthropic_compatible_providers { - registry.update(cx, |registry, cx| { - register_compatible_providers( + registered_anthropic_compatible_providers = register_new_compatible_providers( registry, - &anthropic_compatible_providers, - &anthropic_compatible_providers_new, + std::mem::take(&mut registered_anthropic_compatible_providers), + &anthropic_compatible_provider_settings_new, &client, + "Anthropic", AnthropicCompatibleLanguageModelProvider::new, cx, ); }); - anthropic_compatible_providers = anthropic_compatible_providers_new; + openai_compatible_provider_settings = openai_compatible_provider_settings_new; + anthropic_compatible_provider_settings = anthropic_compatible_provider_settings_new; } }) .detach(); } -fn register_compatible_providers( +fn unregister_removed_compatible_providers( registry: &mut LanguageModelRegistry, - old: &HashSet>, - new: &HashSet>, - client: &Arc, - new_provider: fn(Arc, Arc, &mut App) -> T, + registered: &HashSet>, + settings: &HashSet>, cx: &mut Context, -) { - for provider_id in old { - if !new.contains(provider_id) { +) -> HashSet> { + let mut remaining = HashSet::default(); + for provider_id in registered { + if settings.contains(provider_id) { + remaining.insert(provider_id.clone()); + } else { registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); } } + remaining +} + +fn register_new_compatible_providers( + registry: &mut LanguageModelRegistry, + mut registered: HashSet>, + settings: &HashSet>, + client: &Arc, + provider_kind: &'static str, + new_provider: fn(Arc, Arc, &mut App) -> T, + cx: &mut Context, +) -> HashSet> { + for provider_id in settings { + if registered.contains(provider_id) { + continue; + } - for provider_id in new { - if !old.contains(provider_id) { - registry.register_provider( - Arc::new(new_provider(provider_id.clone(), client.http_client(), cx)), - cx, + let language_model_provider_id = LanguageModelProviderId::from(provider_id.clone()); + if registry.provider(&language_model_provider_id).is_some() { + log::warn!( + "Ignoring {provider_kind}-compatible provider `{provider_id}` because another language model provider is already registered with that id" ); + continue; } + + registry.register_provider( + Arc::new(new_provider(provider_id.clone(), client.http_client(), cx)), + cx, + ); + registered.insert(provider_id.clone()); } + registered } fn register_language_model_providers( diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index d3bd1292484062..6ab1a7d7e82217 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -601,7 +601,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() } @@ -733,14 +733,16 @@ pub fn into_anthropic( } pub struct AnthropicEventMapper { + provider_name: LanguageModelProviderName, tool_uses_by_index: HashMap, usage: Usage, stop_reason: StopReason, } impl AnthropicEventMapper { - pub fn new() -> Self { + pub fn new(provider_name: LanguageModelProviderName) -> Self { Self { + provider_name, tool_uses_by_index: HashMap::default(), usage: Usage::default(), stop_reason: StopReason::EndTurn, @@ -755,7 +757,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(LanguageModelCompletionError::from_anthropic( + error, + self.provider_name.clone(), + ))], }) }) } @@ -897,7 +902,10 @@ impl AnthropicEventMapper { vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))] } Event::Error { error } => { - vec![Err(error.into())] + vec![Err(LanguageModelCompletionError::from_anthropic_api( + error, + self.provider_name.clone(), + ))] } _ => Vec::new(), } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 0ada657d86149b..231ad3b4f91fbe 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -2,25 +2,25 @@ use anthropic::{AnthropicError, AnthropicModelMode}; use anyhow::Result; use convert_case::{Case, Casing}; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, - LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, + AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use menu; use settings::{Settings, SettingsStore}; use std::sync::Arc; -use ui::{ElevationIndex, Tooltip, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use crate::provider::anthropic::{ AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic, }; +use crate::provider::util::{ + ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, + ApiCompatibleProviderState, +}; pub use settings::AnthropicCompatibleAvailableModel as AvailableModel; pub use settings::AnthropicCompatibleModelCapabilities as ModelCapabilities; @@ -38,30 +38,14 @@ pub struct AnthropicCompatibleLanguageModelProvider { state: Entity, } -pub struct State { - id: Arc, - api_key_state: ApiKeyState, - settings: AnthropicCompatibleSettings, -} - -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 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, cx) - } - - fn authenticate(&mut self, cx: &mut Context) -> Task> { - 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, cx) +impl ApiCompatibleProviderSettings for AnthropicCompatibleSettings { + fn api_url(&self) -> &str { + &self.api_url } } +pub type State = ApiCompatibleProviderState; + impl AnthropicCompatibleLanguageModelProvider { pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { fn resolve_settings<'a>( @@ -79,28 +63,12 @@ impl AnthropicCompatibleLanguageModelProvider { let Some(settings) = resolve_settings(&this.id, cx).cloned() else { return; }; - if this.settings != settings { - 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, - cx, - ); - this.settings = settings; - cx.notify(); - } + this.update_settings(settings, cx); }) .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, - } + State::new(id.clone(), settings, EnvVar::new(api_key_env_var_name)) }); Self { @@ -202,8 +170,16 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { 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(), + "Anthropic", + "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + window, + cx, + ) + }) + .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { @@ -350,9 +326,10 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { self.model.mode(), ); 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().map_stream(response)) + Ok(AnthropicEventMapper::new(provider_name).map_stream(response)) }); async move { Ok(future.await?.boxed()) }.boxed() } @@ -367,162 +344,3 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { }) } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - const PLACEHOLDER_TEXT: &'static str = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; - - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| InputField::new(window, cx, Self::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 Anthropic-compatible provider API credentials: {error}" - ); - } - } - 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; - } - - 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 Anthropic-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) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .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/cloud.rs b/crates/language_models/src/provider/cloud.rs index 19009013bf84ad..3c602847052dff 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -791,7 +791,7 @@ impl LanguageModel for CloudLanguageModel { Err(err) => anyhow!(err), })?; - let mut mapper = AnthropicEventMapper::new(); + let mut mapper = AnthropicEventMapper::new(provider_name.clone()); Ok(map_cloud_completion_events( Box::pin(response_lines(response, includes_status_messages)), &provider_name, diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index d47ea26c594ab0..fcf66505caa96b 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,15 +1,14 @@ use anyhow::Result; use convert_case::{Case, Casing}; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, + AuthenticateError, EnvVar, 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}, @@ -17,13 +16,15 @@ use open_ai::{ }; use settings::{Settings, SettingsStore}; use std::sync::Arc; -use ui::{ElevationIndex, Tooltip, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use crate::provider::open_ai::{ OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; +use crate::provider::util::{ + ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, + ApiCompatibleProviderState, +}; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; @@ -40,30 +41,14 @@ pub struct OpenAiCompatibleLanguageModelProvider { state: Entity, } -pub struct State { - id: Arc, - api_key_state: ApiKeyState, - settings: OpenAiCompatibleSettings, -} - -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 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, cx) - } - - fn authenticate(&mut self, cx: &mut Context) -> Task> { - 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, cx) +impl ApiCompatibleProviderSettings for OpenAiCompatibleSettings { + fn api_url(&self) -> &str { + &self.api_url } } +pub type State = ApiCompatibleProviderState; + impl OpenAiCompatibleLanguageModelProvider { pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> { @@ -78,27 +63,11 @@ impl OpenAiCompatibleLanguageModelProvider { let Some(settings) = resolve_settings(&this.id, cx).cloned() else { return; }; - if &this.settings != &settings { - 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, - cx, - ); - this.settings = settings; - cx.notify(); - } + this.update_settings(settings, cx); }) .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, - } + State::new(id.clone(), settings, EnvVar::new(api_key_env_var_name)) }); Self { @@ -180,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", + "000000000000000000000000000000000000000000000000000", + window, + cx, + ) + }) + .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { @@ -403,161 +380,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) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .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/util.rs b/crates/language_models/src/provider/util.rs index 6b1cf7afbb7e3a..c12424b59ca4bb 100644 --- a/crates/language_models/src/provider/util.rs +++ b/crates/language_models/src/provider/util.rs @@ -1,4 +1,11 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; + +use ::util::ResultExt; +use anyhow::Result; +use gpui::{Context, Entity, SharedString, Task, Window}; +use language_model::{ApiKeyState, AuthenticateError, EnvVar}; +use ui::{ElevationIndex, Tooltip, prelude::*}; +use ui_input::InputField; /// Parses tool call arguments JSON, treating empty strings as empty objects. /// @@ -11,3 +18,224 @@ pub fn parse_tool_arguments(arguments: &str) -> Result &str; +} + +pub struct ApiCompatibleProviderState { + pub id: Arc, + pub api_key_state: ApiKeyState, + pub settings: S, +} + +impl ApiCompatibleProviderState { + pub fn new(id: Arc, settings: S, api_key_env_var: EnvVar) -> Self { + Self { + id, + api_key_state: ApiKeyState::new(SharedString::new(settings.api_url()), api_key_env_var), + settings, + } + } + + 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, 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, 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, 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; + } + + 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 provider_article = match provider_name.chars().next() { + Some('A' | 'E' | 'I' | 'O' | 'U' | 'a' | 'e' | 'i' | 'o' | 'u') => "an", + _ => "a", + }; + + 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 {provider_article} {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) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .disabled(env_var_set) + .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() + } + } +} From 15111d5ad663c441a9e350a86ebf8b5ec9cf7987 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Tue, 9 Jun 2026 20:46:17 -0700 Subject: [PATCH 04/11] Refactor language model provider compatibility - Centralize and unify handling of compatibility providers - Add CompatibleProviderSettings, CompatibleProviderKind, and reconcile_compatible_providers to compute desired providers - Use built-in provider ids to avoid conflicts and log warnings - Replace ad-hoc registration with a single reconciliation step --- crates/language_models/src/language_models.rs | 241 ++++++++++-------- 1 file changed, 137 insertions(+), 104 deletions(-) diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 30c4198cebec86..6d590e578d7fc4 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -2,13 +2,9 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; -use collections::HashSet; +use collections::{HashMap, HashSet}; use gpui::{App, Context, Entity}; -use http_client::HttpClient; -use language_model::{ - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderState, - LanguageModelRegistry, -}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod extension; @@ -36,8 +32,13 @@ pub use crate::settings::*; pub fn init(user_store: Entity, client: Arc, cx: &mut App) { let registry = LanguageModelRegistry::global(cx); - registry.update(cx, |registry, cx| { + let built_in_provider_ids = registry.update(cx, |registry, cx| { register_language_model_providers(registry, user_store, client.clone(), cx); + registry + .providers() + .into_iter() + .map(|provider| provider.id()) + .collect::>() }); // Subscribe to extension store events to track LLM extension installations @@ -90,140 +91,172 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { }); } - let mut openai_compatible_provider_settings = AllLanguageModelSettings::get_global(cx) - .openai_compatible - .keys() - .cloned() - .collect::>(); - let mut anthropic_compatible_provider_settings = AllLanguageModelSettings::get_global(cx) - .anthropic_compatible - .keys() - .cloned() - .collect::>(); - let mut registered_openai_compatible_providers = HashSet::default(); - let mut registered_anthropic_compatible_providers = HashSet::default(); + let mut compatible_provider_settings = CompatibleProviderSettings::global(cx); + let mut registered_compatible_providers = HashMap::default(); registry.update(cx, |registry, cx| { - registered_openai_compatible_providers = register_new_compatible_providers( + registered_compatible_providers = reconcile_compatible_providers( registry, - std::mem::take(&mut registered_openai_compatible_providers), - &openai_compatible_provider_settings, + std::mem::take(&mut registered_compatible_providers), + &compatible_provider_settings, + &built_in_provider_ids, &client, - "OpenAI", - OpenAiCompatibleLanguageModelProvider::new, - cx, - ); - registered_anthropic_compatible_providers = register_new_compatible_providers( - registry, - std::mem::take(&mut registered_anthropic_compatible_providers), - &anthropic_compatible_provider_settings, - &client, - "Anthropic", - AnthropicCompatibleLanguageModelProvider::new, cx, ); }); cx.observe_global::(move |cx| { - let openai_compatible_provider_settings_new = AllLanguageModelSettings::get_global(cx) - .openai_compatible - .keys() - .cloned() - .collect::>(); - let anthropic_compatible_provider_settings_new = AllLanguageModelSettings::get_global(cx) - .anthropic_compatible - .keys() - .cloned() - .collect::>(); + let compatible_provider_settings_new = CompatibleProviderSettings::global(cx); - if openai_compatible_provider_settings_new != openai_compatible_provider_settings - || anthropic_compatible_provider_settings_new != anthropic_compatible_provider_settings - { + if compatible_provider_settings_new != compatible_provider_settings { registry.update(cx, |registry, cx| { - registered_openai_compatible_providers = unregister_removed_compatible_providers( - registry, - ®istered_openai_compatible_providers, - &openai_compatible_provider_settings_new, - cx, - ); - registered_anthropic_compatible_providers = unregister_removed_compatible_providers( - registry, - ®istered_anthropic_compatible_providers, - &anthropic_compatible_provider_settings_new, - cx, - ); - - registered_openai_compatible_providers = register_new_compatible_providers( - registry, - std::mem::take(&mut registered_openai_compatible_providers), - &openai_compatible_provider_settings_new, - &client, - "OpenAI", - OpenAiCompatibleLanguageModelProvider::new, - cx, - ); - registered_anthropic_compatible_providers = register_new_compatible_providers( + registered_compatible_providers = reconcile_compatible_providers( registry, - std::mem::take(&mut registered_anthropic_compatible_providers), - &anthropic_compatible_provider_settings_new, + std::mem::take(&mut registered_compatible_providers), + &compatible_provider_settings_new, + &built_in_provider_ids, &client, - "Anthropic", - AnthropicCompatibleLanguageModelProvider::new, cx, ); }); - openai_compatible_provider_settings = openai_compatible_provider_settings_new; - anthropic_compatible_provider_settings = anthropic_compatible_provider_settings_new; + compatible_provider_settings = compatible_provider_settings_new; } }) .detach(); } -fn unregister_removed_compatible_providers( - registry: &mut LanguageModelRegistry, - registered: &HashSet>, - settings: &HashSet>, - cx: &mut Context, -) -> HashSet> { - let mut remaining = HashSet::default(); - for provider_id in registered { - if settings.contains(provider_id) { - remaining.insert(provider_id.clone()); - } else { - registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); +#[derive(PartialEq, Eq)] +struct CompatibleProviderSettings { + openai_compatible_provider_ids: HashSet>, + anthropic_compatible_provider_ids: HashSet>, +} + +impl CompatibleProviderSettings { + fn global(cx: &App) -> Self { + let settings = AllLanguageModelSettings::get_global(cx); + Self { + openai_compatible_provider_ids: settings.openai_compatible.keys().cloned().collect(), + anthropic_compatible_provider_ids: settings + .anthropic_compatible + .keys() + .cloned() + .collect(), } } - remaining } -fn register_new_compatible_providers( +#[derive(Clone, Copy, PartialEq, Eq)] +enum CompatibleProviderKind { + OpenAi, + Anthropic, +} + +impl CompatibleProviderKind { + fn name(self) -> &'static str { + match self { + Self::OpenAi => "OpenAI", + Self::Anthropic => "Anthropic", + } + } + + fn register_provider( + self, + registry: &mut LanguageModelRegistry, + provider_id: Arc, + client: &Arc, + cx: &mut Context, + ) { + match self { + Self::OpenAi => registry.register_provider( + Arc::new(OpenAiCompatibleLanguageModelProvider::new( + provider_id, + client.http_client(), + cx, + )), + cx, + ), + Self::Anthropic => registry.register_provider( + Arc::new(AnthropicCompatibleLanguageModelProvider::new( + provider_id, + client.http_client(), + cx, + )), + cx, + ), + } + } +} + +fn reconcile_compatible_providers( registry: &mut LanguageModelRegistry, - mut registered: HashSet>, - settings: &HashSet>, + registered: HashMap, CompatibleProviderKind>, + settings: &CompatibleProviderSettings, + built_in_provider_ids: &HashSet, client: &Arc, - provider_kind: &'static str, - new_provider: fn(Arc, Arc, &mut App) -> T, cx: &mut Context, -) -> HashSet> { - for provider_id in settings { - if registered.contains(provider_id) { +) -> HashMap, CompatibleProviderKind> { + let desired = desired_compatible_providers(settings, built_in_provider_ids); + + for (provider_id, provider_kind) in ®istered { + if desired.get(provider_id) != Some(provider_kind) { + registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); + } + } + + for (provider_id, provider_kind) in &desired { + if registered.get(provider_id) == Some(provider_kind) { continue; } + provider_kind.register_provider(registry, provider_id.clone(), client, cx); + } + + desired +} + +fn desired_compatible_providers( + settings: &CompatibleProviderSettings, + built_in_provider_ids: &HashSet, +) -> HashMap, CompatibleProviderKind> { + let mut desired = HashMap::default(); + insert_compatible_provider_settings( + &mut desired, + &settings.anthropic_compatible_provider_ids, + CompatibleProviderKind::Anthropic, + built_in_provider_ids, + ); + insert_compatible_provider_settings( + &mut desired, + &settings.openai_compatible_provider_ids, + CompatibleProviderKind::OpenAi, + built_in_provider_ids, + ); + desired +} + +fn insert_compatible_provider_settings( + desired: &mut HashMap, CompatibleProviderKind>, + provider_ids: &HashSet>, + provider_kind: CompatibleProviderKind, + built_in_provider_ids: &HashSet, +) { + for provider_id in provider_ids { let language_model_provider_id = LanguageModelProviderId::from(provider_id.clone()); - if registry.provider(&language_model_provider_id).is_some() { + if built_in_provider_ids.contains(&language_model_provider_id) { log::warn!( - "Ignoring {provider_kind}-compatible provider `{provider_id}` because another language model provider is already registered with that id" + "Ignoring {}-compatible provider `{provider_id}` because it conflicts with a built-in language model provider", + provider_kind.name() ); continue; } - registry.register_provider( - Arc::new(new_provider(provider_id.clone(), client.http_client(), cx)), - cx, - ); - registered.insert(provider_id.clone()); + if let Some(previous_provider_kind) = desired.insert(provider_id.clone(), provider_kind) { + log::warn!( + "Using {}-compatible provider `{provider_id}` instead of {}-compatible provider with the same id", + provider_kind.name(), + previous_provider_kind.name() + ); + } } - registered } fn register_language_model_providers( From b1ca86949eaa4c45409e53390d247d28b52a7fb5 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Tue, 9 Jun 2026 20:46:17 -0700 Subject: [PATCH 05/11] Add Anthropic compat icon and refactor providers - Add AiAnthropicCompat icon variant and asset - Refactor Anthropic/OpenAI compatible providers to fetch settings via a global resolver - Update language model error handling to unify HttpResponseError - Clarify API key sourcing in docs (keychain storage and env vars) --- assets/icons/ai_anthropic_compat.svg | 12 ++++++ crates/icons/src/icons.rs | 1 + crates/language_model/src/language_model.rs | 6 ++- .../src/provider/anthropic_compatible.rs | 38 ++++++------------- .../src/provider/open_ai_compatible.rs | 32 ++++++---------- crates/language_models/src/provider/util.rs | 36 ++++++++++++++---- docs/src/ai/llm-providers.md | 8 ++-- 7 files changed, 74 insertions(+), 59 deletions(-) create mode 100644 assets/icons/ai_anthropic_compat.svg diff --git a/assets/icons/ai_anthropic_compat.svg b/assets/icons/ai_anthropic_compat.svg new file mode 100644 index 00000000000000..48f383c3bb55df --- /dev/null +++ b/assets/icons/ai_anthropic_compat.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index a8a4e47cd0046f..a9cb94ddb2693c 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -11,6 +11,7 @@ pub enum IconName { AcpRegistry, Ai, AiAnthropic, + AiAnthropicCompat, AiBedrock, AiClaude, AiDeepSeek, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index feabe1e314891a..7e22e1caf28d8a 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -322,7 +322,11 @@ impl LanguageModelCompletionError { AnthropicError::HttpResponseError { status_code, message, - } => Self::from_http_status(provider, status_code, message, None), + } => Self::HttpResponseError { + provider, + status_code, + message, + }, AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded { provider, retry_after: Some(retry_after), diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 231ad3b4f91fbe..26264d2d1ffd77 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -1,16 +1,15 @@ use anthropic::{AnthropicError, AnthropicModelMode}; use anyhow::Result; -use convert_case::{Case, Casing}; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use settings::{Settings, SettingsStore}; +use settings::Settings; use std::sync::Arc; use ui::IconName; @@ -48,28 +47,15 @@ pub type State = ApiCompatibleProviderState; impl AnthropicCompatibleLanguageModelProvider { pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { - fn resolve_settings<'a>( - id: &'a str, - cx: &'a App, - ) -> Option<&'a AnthropicCompatibleSettings> { - crate::AllLanguageModelSettings::get_global(cx) - .anthropic_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; - }; - this.update_settings(settings, cx); - }) - .detach(); - - let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); - State::new(id.clone(), settings, EnvVar::new(api_key_env_var_name)) - }); + let state = State::new( + id.clone(), + |id, cx| { + crate::AllLanguageModelSettings::get_global(cx) + .anthropic_compatible + .get(id) + }, + cx, + ); Self { id: id.clone().into(), @@ -130,7 +116,7 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { } fn icon(&self) -> IconOrSvg { - IconOrSvg::Icon(IconName::AiAnthropic) + IconOrSvg::Icon(IconName::AiAnthropicCompat) } fn default_model(&self, cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index fcf66505caa96b..c2dbec7a6e97ec 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,10 +1,9 @@ use anyhow::Result; -use convert_case::{Case, Casing}; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, @@ -14,7 +13,7 @@ use open_ai::{ responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response}, stream_completion, }; -use settings::{Settings, SettingsStore}; +use settings::Settings; use std::sync::Arc; use ui::IconName; @@ -51,24 +50,15 @@ pub type State = ApiCompatibleProviderState; impl OpenAiCompatibleLanguageModelProvider { pub fn new(id: Arc, http_client: 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; - }; - this.update_settings(settings, cx); - }) - .detach(); - let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); - State::new(id.clone(), settings, EnvVar::new(api_key_env_var_name)) - }); + let state = State::new( + id.clone(), + |id, cx| { + crate::AllLanguageModelSettings::get_global(cx) + .openai_compatible + .get(id) + }, + cx, + ); Self { id: id.clone().into(), diff --git a/crates/language_models/src/provider/util.rs b/crates/language_models/src/provider/util.rs index c12424b59ca4bb..95d79ab0e625d9 100644 --- a/crates/language_models/src/provider/util.rs +++ b/crates/language_models/src/provider/util.rs @@ -2,8 +2,10 @@ use std::{str::FromStr, sync::Arc}; use ::util::ResultExt; use anyhow::Result; -use gpui::{Context, Entity, SharedString, Task, Window}; +use convert_case::{Case, Casing}; +use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, Window}; use language_model::{ApiKeyState, AuthenticateError, EnvVar}; +use settings::SettingsStore; use ui::{ElevationIndex, Tooltip, prelude::*}; use ui_input::InputField; @@ -30,12 +32,32 @@ pub struct ApiCompatibleProviderState { } impl ApiCompatibleProviderState { - pub fn new(id: Arc, settings: S, api_key_env_var: EnvVar) -> Self { - Self { - id, - api_key_state: ApiKeyState::new(SharedString::new(settings.api_url()), api_key_env_var), - settings, - } + pub fn new( + id: 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, + } + }) } pub fn is_authenticated(&self) -> bool { diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 222ae37d756288..da6569eeed5d57 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -236,10 +236,9 @@ You can configure a model to use [extended thinking](https://docs.anthropic.com/ ### Anthropic API Compatible {#anthropic-api-compatible} -Zed supports using Anthropic compatible APIs by specifying a custom `api_url` and `available_models` for the Anthropic provider. -This is useful for connecting to other hosted services that implement Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) (`/v1/messages`). +Zed supports connecting to other services that implement Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) (`/v1/messages`) by adding a custom provider with its own `api_url` and `available_models`. -You can add a custom, Anthropic-compatible model either via the UI or by editing your settings file. +You can add an Anthropic-compatible provider either via the UI or by editing your settings file. To do it via the UI, go to the Agent Panel settings (`agent: open settings`) and look for the "Add Provider" button to the right of the "LLM Providers" section title. Then, choose "Anthropic" and fill up the input fields available in the modal. @@ -280,7 +279,8 @@ Models also support the optional `default_temperature`, `extra_beta_headers` (se Token counts for Anthropic-compatible models are estimated locally rather than fetched from the provider's API. Note that LLM API keys aren't stored in your settings file. -So, ensure you have it set in your environment variables (`_API_KEY=`) so your settings can pick it up. In the example above, it would be `SOME_PROVIDER_API_KEY=`. +The API key entered when adding the provider, or in the provider's section of the Agent Panel settings, is saved in your keychain. +Zed will also use the `_API_KEY` environment variable if it's defined. In the example above, it would be `SOME_PROVIDER_API_KEY=`. ### DeepSeek {#deepseek} From 42f01ac8abb79ab0efc7eafddd4ec8b0e34c8b0d Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Tue, 9 Jun 2026 21:42:45 -0700 Subject: [PATCH 06/11] Simplify compatible provider registration and shared state --- crates/language_models/src/language_models.rs | 183 +++++------------- crates/language_models/src/provider.rs | 3 +- .../src/provider/anthropic_compatible.rs | 3 +- .../provider/{util.rs => api_compatible.rs} | 10 +- .../src/provider/open_ai_compatible.rs | 8 +- 5 files changed, 55 insertions(+), 152 deletions(-) rename crates/language_models/src/provider/{util.rs => api_compatible.rs} (95%) diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 6c9fc1683825bb..948cedcc0a528d 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::{HashMap, HashSet}; +use collections::HashSet; use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; use language_model::{ @@ -37,7 +37,7 @@ pub use crate::settings::*; pub fn init(user_store: Entity, client: Arc, cx: &mut App) { let credentials_provider = client.credentials_provider(); let registry = LanguageModelRegistry::global(cx); - let built_in_provider_ids = registry.update(cx, |registry, cx| { + registry.update(cx, |registry, cx| { register_language_model_providers( registry, user_store, @@ -45,11 +45,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { credentials_provider.clone(), cx, ); - registry - .providers() - .into_iter() - .map(|provider| provider.id()) - .collect::>() }); // Subscribe to extension store events to track LLM extension installations @@ -108,15 +103,13 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { }); } - let mut compatible_provider_settings = CompatibleProviderSettings::global(cx); - let mut registered_compatible_providers = HashMap::default(); + let mut compatible_providers = CompatibleProviders::from_settings(cx); registry.update(cx, |registry, cx| { - registered_compatible_providers = reconcile_compatible_providers( + register_compatible_providers( registry, - std::mem::take(&mut registered_compatible_providers), - &compatible_provider_settings, - &built_in_provider_ids, + &CompatibleProviders::default(), + &compatible_providers, &client, &credentials_provider, cx, @@ -128,21 +121,19 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { let Some(registry) = registry.upgrade() else { return; }; - let compatible_provider_settings_new = CompatibleProviderSettings::global(cx); - - if compatible_provider_settings_new != compatible_provider_settings { + let compatible_providers_new = CompatibleProviders::from_settings(cx); + if compatible_providers_new != compatible_providers { registry.update(cx, |registry, cx| { - registered_compatible_providers = reconcile_compatible_providers( + register_compatible_providers( registry, - std::mem::take(&mut registered_compatible_providers), - &compatible_provider_settings_new, - &built_in_provider_ids, + &compatible_providers, + &compatible_providers_new, &client, &credentials_provider, cx, ); }); - compatible_provider_settings = compatible_provider_settings_new; + compatible_providers = compatible_providers_new; } }) .detach(); @@ -192,146 +183,64 @@ pub fn update_environment_fallback_model(cx: &mut App) { }); } -#[derive(PartialEq, Eq)] -struct CompatibleProviderSettings { - openai_compatible_provider_ids: HashSet>, - anthropic_compatible_provider_ids: HashSet>, +#[derive(Default, PartialEq, Eq)] +struct CompatibleProviders { + openai: HashSet>, + anthropic: HashSet>, } -impl CompatibleProviderSettings { - fn global(cx: &App) -> Self { +impl CompatibleProviders { + fn from_settings(cx: &App) -> Self { let settings = AllLanguageModelSettings::get_global(cx); Self { - openai_compatible_provider_ids: settings.openai_compatible.keys().cloned().collect(), - anthropic_compatible_provider_ids: settings - .anthropic_compatible - .keys() - .cloned() - .collect(), + openai: settings.openai_compatible.keys().cloned().collect(), + anthropic: settings.anthropic_compatible.keys().cloned().collect(), } } -} -#[derive(Clone, Copy, PartialEq, Eq)] -enum CompatibleProviderKind { - OpenAi, - Anthropic, + fn contains(&self, provider_id: &Arc) -> bool { + self.openai.contains(provider_id) || self.anthropic.contains(provider_id) + } } -impl CompatibleProviderKind { - fn name(self) -> &'static str { - match self { - Self::OpenAi => "OpenAI", - Self::Anthropic => "Anthropic", +fn register_compatible_providers( + registry: &mut LanguageModelRegistry, + old: &CompatibleProviders, + new: &CompatibleProviders, + client: &Arc, + credentials_provider: &Arc, + cx: &mut Context, +) { + for provider_id in old.openai.iter().chain(&old.anthropic) { + if !new.contains(provider_id) { + registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); } } - fn register_provider( - self, - registry: &mut LanguageModelRegistry, - provider_id: Arc, - client: &Arc, - credentials_provider: &Arc, - cx: &mut Context, - ) { - match self { - Self::OpenAi => registry.register_provider( + for provider_id in &new.openai { + if !old.openai.contains(provider_id) { + registry.register_provider( Arc::new(OpenAiCompatibleLanguageModelProvider::new( - provider_id, + provider_id.clone(), client.http_client(), credentials_provider.clone(), cx, )), cx, - ), - Self::Anthropic => registry.register_provider( + ); + } + } + + for provider_id in &new.anthropic { + if !old.anthropic.contains(provider_id) { + registry.register_provider( Arc::new(AnthropicCompatibleLanguageModelProvider::new( - provider_id, + provider_id.clone(), client.http_client(), credentials_provider.clone(), cx, )), cx, - ), - } - } -} - -fn reconcile_compatible_providers( - registry: &mut LanguageModelRegistry, - registered: HashMap, CompatibleProviderKind>, - settings: &CompatibleProviderSettings, - built_in_provider_ids: &HashSet, - client: &Arc, - credentials_provider: &Arc, - cx: &mut Context, -) -> HashMap, CompatibleProviderKind> { - let desired = desired_compatible_providers(settings, built_in_provider_ids); - - for (provider_id, provider_kind) in ®istered { - if desired.get(provider_id) != Some(provider_kind) { - registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); - } - } - - for (provider_id, provider_kind) in &desired { - if registered.get(provider_id) == Some(provider_kind) { - continue; - } - - provider_kind.register_provider( - registry, - provider_id.clone(), - client, - credentials_provider, - cx, - ); - } - - desired -} - -fn desired_compatible_providers( - settings: &CompatibleProviderSettings, - built_in_provider_ids: &HashSet, -) -> HashMap, CompatibleProviderKind> { - let mut desired = HashMap::default(); - insert_compatible_provider_settings( - &mut desired, - &settings.anthropic_compatible_provider_ids, - CompatibleProviderKind::Anthropic, - built_in_provider_ids, - ); - insert_compatible_provider_settings( - &mut desired, - &settings.openai_compatible_provider_ids, - CompatibleProviderKind::OpenAi, - built_in_provider_ids, - ); - desired -} - -fn insert_compatible_provider_settings( - desired: &mut HashMap, CompatibleProviderKind>, - provider_ids: &HashSet>, - provider_kind: CompatibleProviderKind, - built_in_provider_ids: &HashSet, -) { - for provider_id in provider_ids { - let language_model_provider_id = LanguageModelProviderId::from(provider_id.clone()); - if built_in_provider_ids.contains(&language_model_provider_id) { - log::warn!( - "Ignoring {}-compatible provider `{provider_id}` because it conflicts with a built-in language model provider", - provider_kind.name() - ); - continue; - } - - if let Some(previous_provider_kind) = desired.insert(provider_id.clone(), provider_kind) { - log::warn!( - "Using {}-compatible provider `{provider_id}` instead of {}-compatible provider with the same id", - provider_kind.name(), - previous_provider_kind.name() ); } } diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index 9b9793f1506da8..a2f9b04bf71337 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -4,6 +4,7 @@ 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; @@ -17,8 +18,6 @@ pub mod open_ai_compatible; pub mod open_router; pub mod openai_subscribed; pub mod opencode; -pub mod util; - pub mod vercel_ai_gateway; pub mod x_ai; diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index a274bb27363bc7..c002b37b614810 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -15,7 +15,7 @@ use settings::Settings; use std::sync::Arc; use ui::IconName; -use crate::provider::util::{ +use crate::provider::api_compatible::{ ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, ApiCompatibleProviderState, }; @@ -44,7 +44,6 @@ impl ApiCompatibleProviderSettings for AnthropicCompatibleSettings { pub type State = ApiCompatibleProviderState; -/// Convert a settings-defined `available_models` entry into an `anthropic::Model`. fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::Model { let mode = match available.mode.unwrap_or_default() { settings::ModelMode::Default => AnthropicModelMode::Default, diff --git a/crates/language_models/src/provider/util.rs b/crates/language_models/src/provider/api_compatible.rs similarity index 95% rename from crates/language_models/src/provider/util.rs rename to crates/language_models/src/provider/api_compatible.rs index 24907bca01e261..e186f0baf956d0 100644 --- a/crates/language_models/src/provider/util.rs +++ b/crates/language_models/src/provider/api_compatible.rs @@ -15,10 +15,10 @@ pub trait ApiCompatibleProviderSettings: Clone + Default + PartialEq + 'static { } pub struct ApiCompatibleProviderState { - pub id: Arc, + id: Arc, pub api_key_state: ApiKeyState, pub settings: S, - pub credentials_provider: Arc, + credentials_provider: Arc, } impl ApiCompatibleProviderState { @@ -189,16 +189,12 @@ impl Render for ApiCompatibleProviderConfigura 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 provider_article = match provider_name.chars().next() { - Some('A' | 'E' | 'I' | 'O' | 'U' | 'a' | 'e' | 'i' | 'o' | 'u') => "an", - _ => "a", - }; 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 {provider_article} {provider_name}-compatible provider, you need to add an API key." + "To use Zed's agent with an {provider_name}-compatible provider, you need to add an API key." ))) .child( div() diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index e5054d2d92e7ce..1e51216197b86b 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -18,13 +18,13 @@ use settings::Settings; use std::sync::Arc; use ui::IconName; -use crate::provider::open_ai::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, -}; -use crate::provider::util::{ +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; From 690a7e6ab300c9647ed9793765b08815efa2f160 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Thu, 11 Jun 2026 08:39:19 -0700 Subject: [PATCH 07/11] Add custom_headers and prompt_caching for LLMs - Move model name uniqueness check to use model_names and all_unique - Add custom_headers for Anthropic and propagate to requests - Distinguish OpenAI vs Anthropic providers when syncing - Remove cache_configuration from Anthropic models --- .../add_llm_provider_modal.rs | 33 +++++++++++-------- crates/language_models/src/language_models.rs | 27 ++++++++++++--- .../src/provider/anthropic_compatible.rs | 14 +++++--- crates/language_models/src/settings.rs | 6 ++++ crates/settings_content/src/language_model.rs | 7 ++-- docs/src/ai/use-api-access.md | 13 ++++++-- 6 files changed, 72 insertions(+), 28 deletions(-) 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 faac75fec34c57..a349647388b84e 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,11 +1,11 @@ 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 as OpenAiCompatibleAvailableModel, @@ -257,7 +257,6 @@ impl ModelInput { display_name: None, max_tokens: self.parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, tool_override: None, - cache_configuration: None, max_output_tokens: Some(self.parse_u64_field( &self.max_output_tokens, "Max Output Tokens", @@ -269,6 +268,7 @@ impl ModelInput { capabilities: AnthropicCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), + prompt_caching: false, }, }) } @@ -279,6 +279,19 @@ enum ParsedModels { 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, @@ -312,17 +325,6 @@ fn save_provider_to_settings( return Task::ready(Err("API Key cannot be empty".into())); } - let mut model_names: HashSet = HashSet::default(); - for model in &input.models { - let name = match model.parse_name(cx) { - Ok(name) => name, - Err(error) => return Task::ready(Err(error)), - }; - if !model_names.insert(name) { - return Task::ready(Err("Model Names must be unique".into())); - } - } - let models = match provider { LlmCompatibleProvider::OpenAi => input .models @@ -342,6 +344,10 @@ fn save_provider_to_settings( 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); let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes()); cx.spawn(async move |cx| { @@ -373,6 +379,7 @@ fn save_provider_to_settings( AnthropicCompatibleSettingsContent { api_url, available_models, + custom_headers: None, }, ); } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 948cedcc0a528d..a841f2920e7f4a 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -189,6 +189,12 @@ struct CompatibleProviders { anthropic: HashSet>, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum CompatibleProviderKind { + OpenAi, + Anthropic, +} + impl CompatibleProviders { fn from_settings(cx: &App) -> Self { let settings = AllLanguageModelSettings::get_global(cx); @@ -198,8 +204,17 @@ impl CompatibleProviders { } } - fn contains(&self, provider_id: &Arc) -> bool { - self.openai.contains(provider_id) || self.anthropic.contains(provider_id) + // When the same id is configured in both maps, the OpenAI-compatible entry + // wins, so that adding an `anthropic_compatible` entry can never replace an + // existing working `openai_compatible` endpoint with the same name. + fn kind(&self, provider_id: &str) -> Option { + if self.openai.contains(provider_id) { + Some(CompatibleProviderKind::OpenAi) + } else if self.anthropic.contains(provider_id) { + Some(CompatibleProviderKind::Anthropic) + } else { + None + } } } @@ -212,13 +227,13 @@ fn register_compatible_providers( cx: &mut Context, ) { for provider_id in old.openai.iter().chain(&old.anthropic) { - if !new.contains(provider_id) { + if new.kind(provider_id) != old.kind(provider_id) { registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); } } for provider_id in &new.openai { - if !old.openai.contains(provider_id) { + if old.kind(provider_id) != Some(CompatibleProviderKind::OpenAi) { registry.register_provider( Arc::new(OpenAiCompatibleLanguageModelProvider::new( provider_id.clone(), @@ -232,7 +247,9 @@ fn register_compatible_providers( } for provider_id in &new.anthropic { - if !old.anthropic.contains(provider_id) { + if new.kind(provider_id) == Some(CompatibleProviderKind::Anthropic) + && old.kind(provider_id) != Some(CompatibleProviderKind::Anthropic) + { registry.register_provider( Arc::new(AnthropicCompatibleLanguageModelProvider::new( provider_id.clone(), diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index c002b37b614810..7e76f33e8ea7d5 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -27,6 +27,7 @@ pub use settings::AnthropicCompatibleModelCapabilities as ModelCapabilities; pub struct AnthropicCompatibleSettings { pub api_url: String, pub available_models: Vec, + pub custom_headers: CustomHeaders, } pub struct AnthropicCompatibleLanguageModelProvider { @@ -103,8 +104,8 @@ impl AnthropicCompatibleLanguageModelProvider { 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 `cache_configuration`. - let cache_mode = if model.cache_configuration.is_some() { + // user has opted in via the `prompt_caching` capability. + let cache_mode = if capabilities.prompt_caching { AnthropicPromptCacheMode::Legacy } else { AnthropicPromptCacheMode::Disabled @@ -228,9 +229,13 @@ impl AnthropicCompatibleLanguageModel { let http_client = self.http_client.clone(); let provider_name = self.provider_name.clone(); - let (api_key, api_url) = self.state.read_with(cx, |state, _cx| { + 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.api_key_state.key(&api_url), + api_url, + state.settings.custom_headers.clone(), + ) }); let beta_headers = self.model.beta_headers(); @@ -242,7 +247,6 @@ impl AnthropicCompatibleLanguageModel { }); }; - let extra_headers = CustomHeaders::default(); let request = anthropic::stream_completion( http_client.as_ref(), &api_url, diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 2cd74e087dde4d..d2fc9f59a266f8 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -75,11 +75,17 @@ impl settings::Settings for AllLanguageModelSettings { 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, + ), }, ) }) diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 9b73694ce8cc9f..0869930bb685c7 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -41,6 +41,7 @@ pub struct AnthropicSettingsContent { pub struct AnthropicCompatibleSettingsContent { pub api_url: String, pub available_models: Vec, + pub custom_headers: Option>, } #[with_fallible_options] @@ -54,8 +55,6 @@ pub struct AnthropicCompatibleAvailableModel { 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, - /// Configuration of the Anthropic-style prompt caching API. - pub cache_configuration: Option, pub max_output_tokens: Option, #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_temperature: Option, @@ -72,6 +71,9 @@ pub struct AnthropicCompatibleAvailableModel { 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 { @@ -79,6 +81,7 @@ impl Default for AnthropicCompatibleModelCapabilities { Self { tools: true, images: false, + prompt_caching: false, } } } diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index a6d0e6c7753a14..661b82dbbc85ad 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -428,6 +428,9 @@ You can also configure the provider in your settings file: "anthropic_compatible": { "Some Provider": { "api_url": "https://api.someprovider.com", + "custom_headers": { + "X-Some-Header": "some-value" + }, "available_models": [ { "name": "some-model", @@ -436,7 +439,8 @@ You can also configure the provider in your settings file: "max_output_tokens": 32000, "capabilities": { "tools": true, - "images": false + "images": false, + "prompt_caching": false } } ] @@ -450,10 +454,13 @@ 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. -Models also support the optional `default_temperature`, `extra_beta_headers` (sent as `anthropic-beta` headers), `mode`, `cache_configuration`, and `tool_override` fields, which behave the same as in [Custom Anthropic Models](#anthropic-custom-models). +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. -Token counts for Anthropic-compatible models are estimated locally rather than fetched from the provider's API. +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`. From eb34a9326bb2f298394ea1ef7b0cfef35ffed7ab Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Thu, 11 Jun 2026 18:59:31 +0200 Subject: [PATCH 08/11] Resolve provider ID collisions between OpenAI- and Anthropic-compatible settings The registry has a single provider ID namespace, but the same name could be configured in both the openai_compatible and anthropic_compatible settings sections. Which provider won was dependent on registration order, and removing one of the colliding entries left the removed provider registered. Track compatible providers as a map from ID to provider kind, resolving collisions deterministically in favor of the OpenAI-compatible entry (which predates Anthropic-compatible support) with a warning logged for the shadowed entry. Diffing over (id, kind) pairs makes kind changes unregister and re-register the provider correctly. --- crates/language_models/src/language_models.rs | 299 +++++++++++++++--- 1 file changed, 250 insertions(+), 49 deletions(-) diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index a841f2920e7f4a..717da683dc8582 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::{ @@ -184,12 +184,9 @@ pub fn update_environment_fallback_model(cx: &mut App) { } #[derive(Default, PartialEq, Eq)] -struct CompatibleProviders { - openai: HashSet>, - anthropic: HashSet>, -} +struct CompatibleProviders(HashMap, CompatibleProviderKind>); -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] enum CompatibleProviderKind { OpenAi, Anthropic, @@ -198,23 +195,26 @@ enum CompatibleProviderKind { impl CompatibleProviders { fn from_settings(cx: &App) -> Self { let settings = AllLanguageModelSettings::get_global(cx); - Self { - openai: settings.openai_compatible.keys().cloned().collect(), - anthropic: settings.anthropic_compatible.keys().cloned().collect(), - } - } - - // When the same id is configured in both maps, the OpenAI-compatible entry - // wins, so that adding an `anthropic_compatible` entry can never replace an - // existing working `openai_compatible` endpoint with the same name. - fn kind(&self, provider_id: &str) -> Option { - if self.openai.contains(provider_id) { - Some(CompatibleProviderKind::OpenAi) - } else if self.anthropic.contains(provider_id) { - Some(CompatibleProviderKind::Anthropic) - } else { - None + 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) } } @@ -226,39 +226,34 @@ fn register_compatible_providers( credentials_provider: &Arc, cx: &mut Context, ) { - for provider_id in old.openai.iter().chain(&old.anthropic) { - if new.kind(provider_id) != old.kind(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.openai { - if old.kind(provider_id) != Some(CompatibleProviderKind::OpenAi) { - 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, - ); - } - } - - for provider_id in &new.anthropic { - if new.kind(provider_id) == Some(CompatibleProviderKind::Anthropic) - && old.kind(provider_id) != Some(CompatibleProviderKind::Anthropic) - { - registry.register_provider( - Arc::new(AnthropicCompatibleLanguageModelProvider::new( - provider_id.clone(), - client.http_client(), - credentials_provider.clone(), + ), + CompatibleProviderKind::Anthropic => registry.register_provider( + Arc::new(AnthropicCompatibleLanguageModelProvider::new( + provider_id.clone(), + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, - )), - cx, - ); + ), + } } } } @@ -384,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" + ); + } +} From d5eacdd43125f1ec44b6ef3d243ecfd74b65d954 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Thu, 11 Jun 2026 10:15:14 -0700 Subject: [PATCH 09/11] Only remove the registered entry when deleting a compatible provider Deleting a compatible provider from Agent Settings removed its name from both the openai_compatible and anthropic_compatible settings sections, silently destroying a second configuration that happened to share the name. Remove only the entry that is actually registered, mirroring the OpenAI-wins precedence used at registration time; a shadowed anthropic_compatible entry takes over instead of being deleted. --- crates/agent_ui/src/agent_configuration.rs | 151 ++++++++++++++++++--- 1 file changed, 131 insertions(+), 20 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 1c635ebd7100c1..be93d00a709082 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,25 +396,7 @@ impl AgentConfiguration { update_settings_file(fs.clone(), cx, { let provider_id = provider_id.clone(); move |settings, _| { - let key_to_remove = provider_id.0.as_ref(); - - if let Some(ref mut openai_compatible) = settings - .language_models - .as_mut() - .and_then(|lm| lm.openai_compatible.as_mut()) - { - openai_compatible.remove(key_to_remove); - } - - if let Some(ref mut anthropic_compatible) = settings - .language_models - .as_mut() - .and_then(|language_models| { - language_models.anthropic_compatible.as_mut() - }) - { - anthropic_compatible.remove(key_to_remove); - } + remove_compatible_provider(settings, provider_id.0.as_ref()); } }); }) @@ -1562,3 +1544,132 @@ fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> boo .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"]); + } +} From ce6c0116b720902dca57e1b30291e7e279868aa8 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Thu, 11 Jun 2026 19:37:04 -0700 Subject: [PATCH 10/11] Clean up compatible provider placeholders, icon fill, and module layout - Name the API key placeholder strings as constants and use the Anthropic-style sk-ant- prefix for the Anthropic-compatible provider - Make parse_u64_field a free function since it never used self - Use fill="black" in the Anthropic compat icon for consistency with the OpenAI compat icon (icons are tinted at render time, so the source fill is cosmetic) - Restore the blank line in provider.rs to avoid diff churn --- assets/icons/ai_anthropic_compat.svg | 4 +-- .../add_llm_provider_modal.rs | 35 +++++++++---------- crates/language_models/src/provider.rs | 1 + .../src/provider/anthropic_compatible.rs | 4 ++- .../src/provider/open_ai_compatible.rs | 4 ++- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/assets/icons/ai_anthropic_compat.svg b/assets/icons/ai_anthropic_compat.svg index 48f383c3bb55df..aae16efa0556ec 100644 --- a/assets/icons/ai_anthropic_compat.svg +++ b/assets/icons/ai_anthropic_compat.svg @@ -1,7 +1,7 @@ - - + + 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 a349647388b84e..86e556e2e5d67c 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 @@ -205,19 +205,6 @@ impl ModelInput { Ok(name) } - fn parse_u64_field( - &self, - 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"))) - } - fn parse_open_ai_compatible( &self, cx: &App, @@ -225,17 +212,17 @@ impl ModelInput { Ok(OpenAiCompatibleAvailableModel { name: self.parse_name(cx)?, display_name: None, - max_completion_tokens: Some(self.parse_u64_field( + max_completion_tokens: Some(parse_u64_field( &self.max_completion_tokens, "Max Completion Tokens", cx, )?), - max_output_tokens: Some(self.parse_u64_field( + max_output_tokens: Some(parse_u64_field( &self.max_output_tokens, "Max Output Tokens", cx, )?), - max_tokens: self.parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, + max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, reasoning_effort: None, capabilities: OpenAiCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), @@ -255,9 +242,9 @@ impl ModelInput { Ok(AnthropicCompatibleAvailableModel { name: self.parse_name(cx)?, display_name: None, - max_tokens: self.parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, + max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, tool_override: None, - max_output_tokens: Some(self.parse_u64_field( + max_output_tokens: Some(parse_u64_field( &self.max_output_tokens, "Max Output Tokens", cx, @@ -274,6 +261,18 @@ impl ModelInput { } } +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), diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index a2f9b04bf71337..3a1369a2d1c2e4 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -18,6 +18,7 @@ pub mod open_ai_compatible; pub mod open_router; pub mod openai_subscribed; pub mod opencode; + pub mod vercel_ai_gateway; pub mod x_ai; diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 7e76f33e8ea7d5..afc29e2b4aad8d 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -23,6 +23,8 @@ use crate::provider::api_compatible::{ 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, @@ -188,7 +190,7 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { ApiCompatibleProviderConfigurationView::new( self.state.clone(), "Anthropic", - "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + API_KEY_PLACEHOLDER, window, cx, ) diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 1e51216197b86b..c1f9f70a154dae 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -28,6 +28,8 @@ use crate::provider::open_ai::{ 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, @@ -151,7 +153,7 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { ApiCompatibleProviderConfigurationView::new( self.state.clone(), "OpenAI", - "000000000000000000000000000000000000000000000000000", + API_KEY_PLACEHOLDER, window, cx, ) From b2c36159f5e74186e2f2fe6c428f37ba3285e597 Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Thu, 11 Jun 2026 19:50:06 -0700 Subject: [PATCH 11/11] Fix formatting --- crates/agent_ui/src/agent_configuration.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index be93d00a709082..65ddcafff48b03 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1558,9 +1558,7 @@ fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) .as_mut() .and_then(|providers| providers.remove(provider_id)) .is_some(); - if !removed_from_openai - && let Some(providers) = language_models.anthropic_compatible.as_mut() - { + if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() { providers.remove(provider_id); } }