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