language_models: Add Anthropic-compatible provider support in settings (#50381)

Release Notes:

- Added Anthropic-compatible provider support in settings


1. add anthropic-compatible provider via menu


https://github.com/user-attachments/assets/14b33d59-ae08-4215-b05f-9f3896a4a6f2


2. use anthropic-compatible



https://github.com/user-attachments/assets/8bc7f1c5-644e-4528-942e-29150c35fed0

---------

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>
Co-authored-by: Anant Goel <anant@zed.dev>
Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
This commit is contained in:
authored and GitHub committed 2026-06-12 03:00:42 +00:00
1 parent 22852a6431
commit 527ee5c91c
20 files changed
+1672 -557

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1896_18)">
<path d="M11.094 3.09999H8.952L12.858 12.9H15L11.094 3.09999Z" fill="black"/>
<path d="M4.906 3.09999L1 12.9H3.184L3.98284 10.842H8.06915L8.868 12.9H11.052L7.146 3.09999H4.906ZM4.68928 9.02199L6.026 5.57799L7.3627 9.02199H4.68928Z" fill="black"/>
</g>
<path d="M13.5 6C14.6046 6 15.5 6.89543 15.5 8V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H8C6.89543 15.5 6 14.6046 6 13.5V8C6 6.89543 6.89543 6 8 6H13.5ZM10.8916 8.02539C10.0563 8.02539 9.33453 8.27982 8.81934 8.76562C8.30213 9.25335 8.02547 9.94371 8.02539 10.748C8.02539 11.557 8.29852 12.2492 8.81543 12.7373C9.33013 13.2232 10.0521 13.4746 10.8916 13.4746C11.9865 13.4745 12.8545 13.1022 13.3076 12.3525C13.3894 12.2176 13.4521 12.0693 13.4521 11.8857C13.4521 11.4795 13.0933 11.2773 12.7842 11.2773C12.6604 11.2774 12.5292 11.3025 12.4072 11.3779C12.2862 11.4529 12.2058 11.5586 12.1494 11.666L12.1475 11.6689C11.9677 12.0213 11.5535 12.246 10.8955 12.2461C10.4219 12.2461 10.0667 12.0932 9.83008 11.8506C9.59255 11.607 9.44141 11.2389 9.44141 10.748C9.44148 10.264 9.59319 9.89628 9.83203 9.65137C10.0702 9.40725 10.4255 9.25391 10.8916 9.25391C11.4912 9.25399 11.9415 9.50614 12.1289 9.8916V9.89062C12.1888 10.0157 12.276 10.1311 12.4023 10.2129C12.5303 10.2956 12.6724 10.3271 12.8115 10.3271C12.9661 10.3271 13.1303 10.2857 13.2627 10.1758C13.4018 10.0603 13.4746 9.89383 13.4746 9.71582C13.4746 9.61857 13.4542 9.52036 13.4199 9.42773L13.3818 9.33691C12.9749 8.49175 11.9927 8.02548 10.8916 8.02539ZM10.3203 8.97852L10.1494 9.03516C10.2095 9.01178 10.2716 8.99089 10.3359 8.97363C10.3307 8.97505 10.3256 8.97706 10.3203 8.97852ZM10.4814 8.94141C10.4969 8.9385 10.5126 8.93616 10.5283 8.93359C10.5126 8.93617 10.4969 8.9385 10.4814 8.94141ZM10.6709 8.91504C10.6819 8.91399 10.693 8.913 10.7041 8.91211C10.693 8.913 10.6819 8.91399 10.6709 8.91504Z" fill="black" fill-opacity="0.95"/>
<defs>
<clipPath id="clip0_1896_18">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1 3.09999H15V12.9H1V3.09999ZM8 6C6.89543 6 6 6.89543 6 8V13.5C6 14.6046 6.89543 15.5 8 15.5H13.5C14.6046 15.5 15.5 14.6046 15.5 13.5V8C15.5 6.89543 14.6046 6 13.5 6H8Z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -2377,6 +2377,7 @@
"anthropic": {
"api_url": "https://api.anthropic.com",
},
"anthropic_compatible": {},
"bedrock": {},
"google": {
"api_url": "https://generativelanguage.googleapis.com",
+152 -26
View File
@@ -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<str> = 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<T>(providers: Option<&HashMap<Arc<str>, 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"]);
}
}
@@ -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<AvailableModel, SharedString> {
fn parse_name(&self, cx: &App) -> Result<String, SharedString> {
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<OpenAiCompatibleAvailableModel, SharedString> {
Ok(OpenAiCompatibleAvailableModel {
name: self.parse_name(cx)?,
display_name: None,
max_completion_tokens: Some(
self.max_completion_tokens
.read(cx)
.text(cx)
.parse::<u64>()
.map_err(|_| SharedString::from("Max Completion Tokens must be a number"))?,
),
max_output_tokens: Some(
self.max_output_tokens
.read(cx)
.text(cx)
.parse::<u64>()
.map_err(|_| SharedString::from("Max Output Tokens must be a number"))?,
),
max_tokens: self
.max_tokens
.read(cx)
.text(cx)
.parse::<u64>()
.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<AnthropicCompatibleAvailableModel, SharedString> {
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<InputField>,
field_name: &str,
cx: &App,
) -> Result<u64, SharedString> {
field
.read(cx)
.text(cx)
.parse::<u64>()
.map_err(|_| SharedString::from(format!("{field_name} must be a number")))
}
enum ParsedModels {
OpenAi(Vec<OpenAiCompatibleAvailableModel>),
Anthropic(Vec<AnthropicCompatibleAvailableModel>),
}
impl ParsedModels {
fn model_names(&self) -> impl Iterator<Item = &str> {
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<Result<(), SharedString>> {
@@ -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<String> = 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::<Result<Vec<_>, _>>()
.map(ParsedModels::OpenAi),
LlmCompatibleProvider::Anthropic => input
.models
.iter()
.map(|model| model.parse_anthropic_compatible(cx))
.collect::<Result<Vec<_>, _>>()
.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 = <dyn 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<Self>) {
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<Self>) -> 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()
+77 -61
View File
@@ -957,73 +957,89 @@ impl From<language_model_core::Speed> for Speed {
impl From<AnthropicError> 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<ApiError> 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()),
}
}
+15 -6
View File
@@ -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<usize, RawToolUse>,
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(),
}
+1
View File
@@ -10,6 +10,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr};
pub enum IconName {
AcpRegistry,
AiAnthropic,
AiAnthropicCompat,
AiBedrock,
AiClaude,
AiDeepSeek,
+285 -40
View File
@@ -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<UserStore>, client: Arc<Client>, cx: &mut App) {
});
}
let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx)
.openai_compatible
.keys()
.cloned()
.collect::<HashSet<_>>();
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<UserStore>, client: Arc<Client>, cx: &mut App) {
let Some(registry) = registry.upgrade() else {
return;
};
let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx)
.openai_compatible
.keys()
.cloned()
.collect::<HashSet<_>>();
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<Arc<str>, 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<Arc<str>, 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<Arc<str>>,
new: &HashSet<Arc<str>>,
client: Arc<Client>,
credentials_provider: Arc<dyn CredentialsProvider>,
old: &CompatibleProviders,
new: &CompatibleProviders,
client: &Arc<Client>,
credentials_provider: &Arc<dyn CredentialsProvider>,
cx: &mut Context<LanguageModelRegistry>,
) {
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<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + '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<Box<dyn Future<Output = Result<()>> + 'a>> {
Box::pin(async { Ok(()) })
}
fn delete_credentials<'a>(
&'a self,
_url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
Box::pin(async { Ok(()) })
}
}
fn init_test(cx: &mut App) -> (Arc<Client>, Arc<dyn CredentialsProvider>) {
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::<serde_json::Map<String, serde_json::Value>>()
.into()
}
let content = serde_json::json!({
"language_models": {
"openai_compatible": section(openai),
"anthropic_compatible": section(anthropic),
}
})
.to_string();
cx.update_global::<SettingsStore, _>(|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<IconOrSvg> {
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"
);
}
}
+2
View File
@@ -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;
@@ -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()
}
@@ -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<AvailableModel>,
pub custom_headers: CustomHeaders,
}
pub struct AnthropicCompatibleLanguageModelProvider {
id: LanguageModelProviderId,
name: LanguageModelProviderName,
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
impl ApiCompatibleProviderSettings for AnthropicCompatibleSettings {
fn api_url(&self) -> &str {
&self.api_url
}
}
pub type State = ApiCompatibleProviderState<AnthropicCompatibleSettings>;
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<str>,
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
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<dyn LanguageModel> {
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<Entity<Self::ObservableEntity>> {
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<Arc<dyn LanguageModel>> {
self.state
.read(cx)
.settings
.available_models
.first()
.map(|model| self.create_language_model(model.clone()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
None
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
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<Result<(), AuthenticateError>> {
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<Result<()>> {
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<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl AnthropicCompatibleLanguageModel {
fn stream_completion(
&self,
request: anthropic::Request,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
BoxStream<'static, Result<anthropic::Event, AnthropicError>>,
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<u64> {
Some(self.model.max_output_tokens)
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
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()
}
}
@@ -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<S: ApiCompatibleProviderSettings> {
id: Arc<str>,
pub api_key_state: ApiKeyState,
pub settings: S,
credentials_provider: Arc<dyn CredentialsProvider>,
}
impl<S: ApiCompatibleProviderSettings> ApiCompatibleProviderState<S> {
pub fn new(
id: Arc<str>,
credentials_provider: Arc<dyn CredentialsProvider>,
resolve_settings: for<'a> fn(&'a str, &'a App) -> Option<&'a S>,
cx: &mut App,
) -> Entity<Self> {
let api_key_env_var_name: SharedString =
format!("{}_API_KEY", id).to_case(Case::UpperSnake).into();
cx.new(|cx| {
cx.observe_global::<SettingsStore>(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<String>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
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<Self>) -> Task<Result<(), AuthenticateError>> {
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<Self>) {
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<S: ApiCompatibleProviderSettings> {
api_key_editor: Entity<InputField>,
state: Entity<ApiCompatibleProviderState<S>>,
provider_name: &'static str,
load_credentials_task: Option<Task<()>>,
}
impl<S: ApiCompatibleProviderSettings> ApiCompatibleProviderConfigurationView<S> {
pub fn new(
state: Entity<ApiCompatibleProviderState<S>>,
provider_name: &'static str,
placeholder_text: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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>) {
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<Self>) -> bool {
!self.state.read(cx).is_authenticated()
}
}
impl<S: ApiCompatibleProviderSettings> Render for ApiCompatibleProviderConfigurationView<S> {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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()
}
}
}
@@ -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
@@ -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<OpenAiCompatibleSettings>;
pub struct OpenAiCompatibleLanguageModelProvider {
id: LanguageModelProviderId,
name: LanguageModelProviderName,
@@ -42,42 +52,6 @@ pub struct OpenAiCompatibleLanguageModelProvider {
state: Entity<State>,
}
pub struct State {
id: Arc<str>,
api_key_state: ApiKeyState,
settings: OpenAiCompatibleSettings,
credentials_provider: Arc<dyn CredentialsProvider>,
}
impl State {
fn is_authenticated(&self) -> bool {
self.api_key_state.has_key()
}
fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
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<Self>) -> Task<Result<(), AuthenticateError>> {
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<str>,
@@ -85,43 +59,16 @@ impl OpenAiCompatibleLanguageModelProvider {
credentials_provider: Arc<dyn CredentialsProvider>,
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::<SettingsStore>(|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<Result<()>> {
@@ -416,159 +371,3 @@ impl LanguageModel for OpenAiCompatibleLanguageModel {
}
}
}
struct ConfigurationView {
api_key_editor: Entity<InputField>,
state: Entity<State>,
load_credentials_task: Option<Task<()>>,
}
impl ConfigurationView {
fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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>) {
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<Self>) -> bool {
!self.state.read(cx).is_authenticated()
}
}
impl Render for ConfigurationView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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()
}
}
}
@@ -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()
+26 -6
View File
@@ -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<Arc<str>, 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(
@@ -501,7 +501,7 @@ impl<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
)
.await?;
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,
@@ -11,6 +11,7 @@ use std::sync::Arc;
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct AllLanguageModelSettingsContent {
pub anthropic: Option<AnthropicSettingsContent>,
pub anthropic_compatible: Option<HashMap<Arc<str>, AnthropicCompatibleSettingsContent>>,
pub bedrock: Option<AmazonBedrockSettingsContent>,
pub deepseek: Option<DeepseekSettingsContent>,
pub google: Option<GoogleSettingsContent>,
@@ -35,6 +36,56 @@ pub struct AnthropicSettingsContent {
pub custom_headers: Option<HashMap<String, String>>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct AnthropicCompatibleSettingsContent {
pub api_url: String,
pub available_models: Vec<AnthropicCompatibleAvailableModel>,
pub custom_headers: Option<HashMap<String, String>>,
}
#[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<String>,
/// 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<String>,
pub max_output_tokens: Option<u64>,
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub default_temperature: Option<f32>,
#[serde(default)]
pub extra_beta_headers: Vec<String>,
/// The model's mode (e.g. thinking)
pub mode: Option<ModelMode>,
#[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 {
+4
View File
@@ -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).
+51
View File
@@ -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 (`<PROVIDER_NAME>_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.