logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
[package]
name = "language_models"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/language_models.rs"
[dependencies]
ai_onboarding.workspace = true
async-lock.workspace = true
anthropic = { workspace = true, features = ["schemars"] }
anyhow.workspace = true
aws-config = { workspace = true, features = ["behavior-version-latest"] }
aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] }
aws_http_client.workspace = true
base64.workspace = true
bedrock = { workspace = true, features = ["schemars"] }
client.workspace = true
cloud_api_client.workspace = true
cloud_api_types.workspace = true
collections.workspace = true
component.workspace = true
convert_case.workspace = true
copilot.workspace = true
copilot_chat.workspace = true
copilot_ui.workspace = true
credentials_provider.workspace = true
deepseek = { workspace = true, features = ["schemars"] }
extension.workspace = true
extension_host.workspace = true
fs.workspace = true
futures.workspace = true
google_ai = { workspace = true, features = ["schemars"] }
gpui.workspace = true
gpui_tokio.workspace = true
http_client.workspace = true
language.workspace = true
language_model.workspace = true
language_models_cloud.workspace = true
lmstudio = { workspace = true, features = ["schemars"] }
log.workspace = true
menu.workspace = true
mistral = { workspace = true, features = ["schemars"] }
ollama = { workspace = true, features = ["schemars"] }
open_ai = { workspace = true, features = ["schemars"] }
opencode = { workspace = true, features = ["schemars"] }
open_router = { workspace = true, features = ["schemars"] }
release_channel.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
strum.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
ui.workspace = true
ui_input.workspace = true
util.workspace = true
x_ai = { workspace = true, features = ["schemars"] }
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
db = { workspace = true, features = ["test-support"] }
feature_flags.workspace = true
gpui = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
settings = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,67 @@
use collections::HashMap;
use extension::{
ExtensionHostProxy, ExtensionLanguageModelProviderProxy, LanguageModelProviderRegistration,
};
use gpui::{App, Entity};
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use std::sync::{Arc, LazyLock};
/// Maps built-in provider IDs to their corresponding extension IDs.
/// When an extension with this ID is installed, the built-in provider should be hidden.
static BUILTIN_TO_EXTENSION_MAP: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
let mut map = HashMap::default();
map.insert("anthropic", "anthropic");
map.insert("openai", "openai");
map.insert("google", "google-ai");
map.insert("openrouter", "openrouter");
map.insert("copilot_chat", "copilot-chat");
map
});
/// Returns the extension ID that should hide the given built-in provider.
pub fn extension_for_builtin_provider(provider_id: &str) -> Option<&'static str> {
BUILTIN_TO_EXTENSION_MAP.get(provider_id).copied()
}
/// Proxy that registers extension language model providers with the LanguageModelRegistry.
pub struct LanguageModelProviderRegistryProxy {
registry: Entity<LanguageModelRegistry>,
}
impl LanguageModelProviderRegistryProxy {
pub fn new(registry: Entity<LanguageModelRegistry>) -> Self {
Self { registry }
}
}
impl ExtensionLanguageModelProviderProxy for LanguageModelProviderRegistryProxy {
fn register_language_model_provider(
&self,
_provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
) {
register_fn(cx);
}
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
self.registry.update(cx, |registry, cx| {
registry.unregister_provider(LanguageModelProviderId::from(provider_id), cx);
});
}
}
/// Initialize the extension language model provider proxy.
/// This must be called BEFORE extension_host::init to ensure the proxy is available
/// when extensions try to register their language model providers.
pub fn init_proxy(cx: &mut App) {
let proxy = ExtensionHostProxy::default_global(cx);
let registry = LanguageModelRegistry::global(cx);
registry.update(cx, |registry, _cx| {
registry.set_builtin_provider_hiding_fn(Box::new(extension_for_builtin_provider));
});
proxy.register_language_model_provider_proxy(LanguageModelProviderRegistryProxy::new(registry));
}

View File

@@ -0,0 +1,333 @@
use std::sync::Arc;
use ::settings::{Settings, SettingsStore};
use client::{Client, UserStore};
use collections::HashSet;
use credentials_provider::CredentialsProvider;
use gpui::{App, Context, Entity};
use language_model::{
ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
};
use provider::deepseek::DeepSeekLanguageModelProvider;
pub mod extension;
pub mod provider;
mod settings;
pub use crate::extension::init_proxy as init_extension_proxy;
use crate::provider::anthropic::AnthropicLanguageModelProvider;
use crate::provider::bedrock::BedrockLanguageModelProvider;
use crate::provider::cloud::CloudLanguageModelProvider;
use crate::provider::copilot_chat::CopilotChatLanguageModelProvider;
use crate::provider::google::GoogleLanguageModelProvider;
use crate::provider::lmstudio::LmStudioLanguageModelProvider;
pub use crate::provider::mistral::MistralLanguageModelProvider;
use crate::provider::ollama::OllamaLanguageModelProvider;
use crate::provider::open_ai::OpenAiLanguageModelProvider;
use crate::provider::open_ai_compatible::OpenAiCompatibleLanguageModelProvider;
use crate::provider::open_router::OpenRouterLanguageModelProvider;
use crate::provider::opencode::OpenCodeLanguageModelProvider;
use crate::provider::vercel_ai_gateway::VercelAiGatewayLanguageModelProvider;
use crate::provider::x_ai::XAiLanguageModelProvider;
pub use crate::settings::*;
pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
let credentials_provider = client.credentials_provider();
let registry = LanguageModelRegistry::global(cx);
registry.update(cx, |registry, cx| {
register_language_model_providers(
registry,
user_store,
client.clone(),
credentials_provider.clone(),
cx,
);
});
// Subscribe to extension store events to track LLM extension installations
if let Some(extension_store) = extension_host::ExtensionStore::try_global(cx) {
cx.subscribe(&extension_store, {
let registry = registry.downgrade();
move |extension_store, event, cx| {
let Some(registry) = registry.upgrade() else {
return;
};
match event {
extension_host::Event::ExtensionInstalled(extension_id) => {
if let Some(manifest) = extension_store
.read(cx)
.extension_manifest_for_id(extension_id)
{
if !manifest.language_model_providers.is_empty() {
registry.update(cx, |registry, cx| {
registry.extension_installed(extension_id.clone(), cx);
});
}
}
}
extension_host::Event::ExtensionUninstalled(extension_id) => {
registry.update(cx, |registry, cx| {
registry.extension_uninstalled(extension_id, cx);
});
}
extension_host::Event::ExtensionsUpdated => {
let mut new_ids = HashSet::default();
for (extension_id, entry) in extension_store.read(cx).installed_extensions()
{
if !entry.manifest.language_model_providers.is_empty() {
new_ids.insert(extension_id.clone());
}
}
registry.update(cx, |registry, cx| {
registry.sync_installed_llm_extensions(new_ids, cx);
});
}
_ => {}
}
}
})
.detach();
// Initialize with currently installed extensions
registry.update(cx, |registry, cx| {
let mut initial_ids = HashSet::default();
for (extension_id, entry) in extension_store.read(cx).installed_extensions() {
if !entry.manifest.language_model_providers.is_empty() {
initial_ids.insert(extension_id.clone());
}
}
registry.sync_installed_llm_extensions(initial_ids, cx);
});
}
let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx)
.openai_compatible
.keys()
.cloned()
.collect::<HashSet<_>>();
registry.update(cx, |registry, cx| {
register_openai_compatible_providers(
registry,
&HashSet::default(),
&openai_compatible_providers,
client.clone(),
credentials_provider.clone(),
cx,
);
});
let registry = registry.downgrade();
cx.observe_global::<SettingsStore>(move |cx| {
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 {
registry.update(cx, |registry, cx| {
register_openai_compatible_providers(
registry,
&openai_compatible_providers,
&openai_compatible_providers_new,
client.clone(),
credentials_provider.clone(),
cx,
);
});
openai_compatible_providers = openai_compatible_providers_new;
}
})
.detach();
}
/// Recomputes and sets the [`LanguageModelRegistry`]'s environment fallback
/// model based on currently authenticated providers.
///
/// Prefers the Zed cloud provider so that, once the user is signed in, we
/// always pick a Zed-hosted model over models from other authenticated
/// providers in the environment. If the Zed cloud provider is authenticated
/// but hasn't finished loading its models yet, we don't fall back to another
/// provider to avoid flickering between providers during sign in.
pub fn update_environment_fallback_model(cx: &mut App) {
let registry = LanguageModelRegistry::global(cx);
let fallback_model = {
let registry = registry.read(cx);
let cloud_provider = registry.provider(&ZED_CLOUD_PROVIDER_ID);
if cloud_provider
.as_ref()
.is_some_and(|provider| provider.is_authenticated(cx))
{
cloud_provider.and_then(|provider| {
let model = provider
.default_model(cx)
.or_else(|| provider.recommended_models(cx).first().cloned())?;
Some(ConfiguredModel { provider, model })
})
} else {
registry
.providers()
.iter()
.filter(|provider| provider.is_authenticated(cx))
.find_map(|provider| {
let model = provider
.default_model(cx)
.or_else(|| provider.recommended_models(cx).first().cloned())?;
Some(ConfiguredModel {
provider: provider.clone(),
model,
})
})
}
};
registry.update(cx, |registry, cx| {
registry.set_environment_fallback_model(fallback_model, cx);
});
}
fn register_openai_compatible_providers(
registry: &mut LanguageModelRegistry,
old: &HashSet<Arc<str>>,
new: &HashSet<Arc<str>>,
client: Arc<Client>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut Context<LanguageModelRegistry>,
) {
for provider_id in old {
if !new.contains(provider_id) {
registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx);
}
}
for provider_id in new {
if !old.contains(provider_id) {
registry.register_provider(
Arc::new(OpenAiCompatibleLanguageModelProvider::new(
provider_id.clone(),
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
}
}
}
fn register_language_model_providers(
registry: &mut LanguageModelRegistry,
user_store: Entity<UserStore>,
client: Arc<Client>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut Context<LanguageModelRegistry>,
) {
registry.register_provider(
Arc::new(CloudLanguageModelProvider::new(
user_store,
client.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(AnthropicLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(OpenAiLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(OllamaLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(LmStudioLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(DeepSeekLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(GoogleLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
MistralLanguageModelProvider::global(
client.http_client(),
credentials_provider.clone(),
cx,
),
cx,
);
registry.register_provider(
Arc::new(BedrockLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(OpenRouterLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(VercelAiGatewayLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(XAiLanguageModelProvider::new(
client.http_client(),
credentials_provider.clone(),
cx,
)),
cx,
);
registry.register_provider(
Arc::new(OpenCodeLanguageModelProvider::new(
client.http_client(),
credentials_provider,
cx,
)),
cx,
);
registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx);
}

View File

@@ -0,0 +1,16 @@
pub mod anthropic;
pub mod bedrock;
pub mod cloud;
pub mod copilot_chat;
pub mod deepseek;
pub mod google;
pub mod lmstudio;
pub mod mistral;
pub mod ollama;
pub mod open_ai;
pub mod open_ai_compatible;
pub mod open_router;
pub mod opencode;
pub mod vercel_ai_gateway;
pub mod x_ai;

View File

@@ -0,0 +1,655 @@
pub mod telemetry;
use anthropic::{ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode};
use anyhow::Result;
use collections::BTreeMap;
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
use http_client::HttpClient;
use language_model::{
ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError,
ConfigurationViewTargetAgent, EnvVar, IconOrSvg, LanguageModel,
LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, RateLimiter, env_var,
};
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
pub use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic};
pub use settings::AnthropicAvailableModel as AvailableModel;
const PROVIDER_ID: LanguageModelProviderId = ANTHROPIC_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = ANTHROPIC_PROVIDER_NAME;
#[derive(Default, Clone, Debug, PartialEq)]
pub struct AnthropicSettings {
pub api_url: String,
/// Extend Zed's list of Anthropic models.
pub available_models: Vec<AvailableModel>,
}
pub struct AnthropicLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
const API_KEY_ENV_VAR_NAME: &str = "ANTHROPIC_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
pub struct State {
api_key_state: ApiKeyState,
credentials_provider: Arc<dyn CredentialsProvider>,
http_client: Arc<dyn HttpClient>,
fetched_models: Vec<anthropic::Model>,
fetch_models_task: Option<Task<Result<()>>>,
}
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 = AnthropicLanguageModelProvider::api_url(cx);
let should_fetch_models = api_key.is_some();
let task = self.api_key_state.store(
api_url,
api_key,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
self.fetched_models.clear();
cx.spawn(async move |this, cx| {
let result = task.await;
if result.is_ok() && should_fetch_models {
this.update(cx, |this, cx| this.restart_fetch_models_task(cx))
.ok();
}
result
})
}
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let credentials_provider = self.credentials_provider.clone();
let api_url = AnthropicLanguageModelProvider::api_url(cx);
let task = self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.spawn(async move |this, cx| {
let result = task.await;
if result.is_ok() {
this.update(cx, |this, cx| this.restart_fetch_models_task(cx))
.ok();
}
result
})
}
fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let http_client = self.http_client.clone();
let api_url = AnthropicLanguageModelProvider::api_url(cx);
let Some(api_key) = self.api_key_state.key(&api_url) else {
return Task::ready(Err(anyhow::anyhow!(
"cannot fetch Anthropic models without an API key"
)));
};
cx.spawn(async move |this, cx| {
let models =
anthropic::list_models(http_client.as_ref(), &api_url, api_key.as_ref()).await?;
this.update(cx, |this, cx| {
this.fetched_models = models;
cx.notify();
})
})
}
fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
let task = self.fetch_models(cx);
self.fetch_models_task.replace(task);
}
}
impl AnthropicLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>({
let mut last_api_url = Self::api_url(cx);
move |this: &mut State, cx| {
let credentials_provider = this.credentials_provider.clone();
let api_url = Self::api_url(cx);
let url_changed = api_url != last_api_url;
last_api_url = api_url.clone();
this.api_key_state.handle_url_change(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
if url_changed {
this.fetched_models.clear();
this.authenticate(cx).detach();
}
cx.notify();
}
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
http_client: http_client.clone(),
fetched_models: Vec::new(),
fetch_models_task: None,
}
});
Self { http_client, state }
}
fn create_language_model(&self, model: anthropic::Model) -> Arc<dyn LanguageModel> {
Arc::new(AnthropicModel {
id: LanguageModelId::from(model.id.to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
fn settings(cx: &App) -> &AnthropicSettings {
&crate::AllLanguageModelSettings::get_global(cx).anthropic
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
ANTHROPIC_API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
}
impl LanguageModelProviderState for AnthropicLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for AnthropicLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiAnthropic)
}
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
let fetched = self.state.read(cx).fetched_models.clone();
// Pick the highest-version Sonnet we know about; otherwise the first
// Claude model returned. Returning `None` until the fetch completes
// matches the Ollama provider's behavior.
pick_preferred_model(&fetched, &["claude-sonnet-", "claude-opus-", "claude-"])
.map(|model| self.create_language_model(model))
}
fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
let fetched = self.state.read(cx).fetched_models.clone();
pick_preferred_model(&fetched, &["claude-haiku-", "claude-"])
.map(|model| self.create_language_model(model))
}
fn recommended_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let fetched = self.state.read(cx).fetched_models.clone();
pick_preferred_model(&fetched, &["claude-sonnet-"])
.map(|model| vec![self.create_language_model(model)])
.unwrap_or_default()
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models: BTreeMap<String, anthropic::Model> = BTreeMap::default();
// Models reported by Anthropic's `/v1/models` endpoint are the
// primary source. The list will be empty until authentication has
// succeeded and the first fetch completes.
for model in &self.state.read(cx).fetched_models {
models.insert(model.id.to_string(), model.clone());
}
// User-defined `available_models` from settings can either add
// entirely new entries or override fields on a fetched model with
// the same id (e.g. enable Fast mode or set a tool override).
for available in &AnthropicLanguageModelProvider::settings(cx).available_models {
let model = available_model_to_anthropic_model(available);
models.insert(model.id.to_string(), model);
}
models
.into_values()
.map(|model| self.create_language_model(model))
.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: ConfigurationViewTargetAgent,
window: &mut Window,
cx: &mut App,
) -> AnyView {
cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx))
.into()
}
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
self.state
.update(cx, |state, cx| state.set_api_key(None, cx))
}
}
/// Pick the model from `models` whose id starts with the earliest matching
/// prefix in `preferred_prefixes`. Within a single prefix bucket the model
/// with the lexicographically greatest id wins, which roughly corresponds to
/// the highest version since Anthropic ids embed dated suffixes.
fn pick_preferred_model(
models: &[anthropic::Model],
preferred_prefixes: &[&str],
) -> Option<anthropic::Model> {
for prefix in preferred_prefixes {
let candidate = models
.iter()
.filter(|m| m.id.starts_with(prefix))
.max_by(|a, b| a.id.cmp(&b.id));
if let Some(model) = candidate {
return Some(model.clone());
}
}
None
}
/// Convert a settings-defined `available_models` entry into an `anthropic::Model`.
fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::Model {
let mode = match available.mode.unwrap_or_default() {
settings::ModelMode::Default => AnthropicModelMode::Default,
settings::ModelMode::Thinking { budget_tokens } => {
AnthropicModelMode::Thinking { budget_tokens }
}
};
let supports_thinking = matches!(
mode,
AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking
);
let supports_adaptive_thinking = matches!(mode, AnthropicModelMode::AdaptiveThinking);
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,
supports_images: true,
supports_speed: false,
supported_effort_levels: if supports_adaptive_thinking {
vec![
anthropic::Effort::Low,
anthropic::Effort::Medium,
anthropic::Effort::High,
anthropic::Effort::XHigh,
anthropic::Effort::Max,
]
} else {
vec![]
},
tool_override: available.tool_override.clone(),
extra_beta_headers: available.extra_beta_headers.clone(),
}
}
pub struct AnthropicModel {
id: LanguageModelId,
model: anthropic::Model,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl AnthropicModel {
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 (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = AnthropicLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
let beta_headers = self.model.beta_headers();
async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey {
provider: PROVIDER_NAME,
});
};
let request = anthropic::stream_completion(
http_client.as_ref(),
&api_url,
&api_key,
request,
beta_headers,
);
request.await.map_err(Into::into)
}
.boxed()
}
}
impl LanguageModel for AnthropicModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(self.model.display_name.clone())
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
true
}
fn supports_images(&self) -> bool {
self.model.supports_images
}
fn supports_streaming_tools(&self) -> bool {
true
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto
| LanguageModelToolChoice::Any
| LanguageModelToolChoice::None => true,
}
}
fn supports_thinking(&self) -> bool {
self.model.supports_thinking
}
fn supports_fast_mode(&self) -> bool {
self.model.supports_speed
}
fn supported_effort_levels(&self) -> Vec<language_model::LanguageModelEffortLevel> {
self.model
.supported_effort_levels
.iter()
.map(|e| {
let is_default = matches!(e, anthropic::Effort::High);
let (name, value) = match e {
anthropic::Effort::Low => ("Low".into(), "low".into()),
anthropic::Effort::Medium => ("Medium".into(), "medium".into()),
anthropic::Effort::High => ("High".into(), "high".into()),
anthropic::Effort::XHigh => ("XHigh".into(), "xhigh".into()),
anthropic::Effort::Max => ("Max".into(), "max".into()),
};
language_model::LanguageModelEffortLevel {
name,
value,
is_default,
}
})
.collect::<Vec<_>>()
}
fn telemetry_id(&self) -> String {
format!("anthropic/{}", self.model.id)
}
fn api_key(&self, cx: &App) -> Option<String> {
self.state.read_with(cx, |state, cx| {
let api_url = AnthropicLanguageModelProvider::api_url(cx);
state.api_key_state.key(&api_url).map(|key| key.to_string())
})
}
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(),
AnthropicPromptCacheMode::Automatic,
);
if !self.model.supports_speed {
request.speed = None;
}
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))
});
async move { Ok(future.await?.boxed()) }.boxed()
}
fn cache_configuration(&self) -> Option<LanguageModelCacheConfiguration> {
None
}
}
struct ConfigurationView {
api_key_editor: Entity<InputField>,
state: Entity<State>,
load_credentials_task: Option<Task<()>>,
target_agent: ConfigurationViewTargetAgent,
}
impl ConfigurationView {
const PLACEHOLDER_TEXT: &'static str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
fn new(
state: Entity<State>,
target_agent: ConfigurationViewTargetAgent,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
cx.observe(&state, |_, _, cx| {
cx.notify();
})
.detach();
let load_credentials_task = Some(cx.spawn({
let state = state.clone();
async move |this, cx| {
let task = 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: cx.new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_TEXT)),
state,
load_credentials_task,
target_agent,
}
}
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);
if api_key.is_empty() {
return;
}
// url changes can cause the editor to be displayed again
self.api_key_editor
.update(cx, |editor, cx| editor.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, |editor, cx| editor.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: &mut 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 env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
} else {
let api_url = AnthropicLanguageModelProvider::api_url(cx);
if api_url == ANTHROPIC_API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
if self.load_credentials_task.is_some() {
div()
.child(Label::new("Loading credentials..."))
.into_any_element()
} else if self.should_render_editor(cx) {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(),
ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
})))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys"))
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
)
)
.child(self.api_key_editor.clone())
.child(
Label::new(
format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
)
.size(LabelSize::Small)
.color(Color::Muted)
.mt_0p5(),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.when(env_var_set, |this| {
this.tooltip_label(format!(
"To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."
))
})
.into_any_element()
}
}
}

View File

@@ -0,0 +1,136 @@
use anthropic::ANTHROPIC_API_URL;
use anyhow::{Context as _, anyhow};
use gpui::BackgroundExecutor;
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use language_model::{ANTHROPIC_PROVIDER_ID, LanguageModel};
use std::env;
use std::sync::Arc;
use util::ResultExt;
#[derive(Clone, Debug)]
pub struct AnthropicEventData {
pub completion_type: AnthropicCompletionType,
pub event: AnthropicEventType,
pub language_name: Option<String>,
pub message_id: Option<String>,
}
#[derive(Clone, Debug)]
pub enum AnthropicCompletionType {
Editor,
Terminal,
Panel,
}
#[derive(Clone, Debug)]
pub enum AnthropicEventType {
Invoked,
Response,
Accept,
Reject,
}
impl AnthropicCompletionType {
fn as_str(&self) -> &'static str {
match self {
Self::Editor => "natural_language_completion_in_editor",
Self::Terminal => "natural_language_completion_in_terminal",
Self::Panel => "conversation_message",
}
}
}
impl AnthropicEventType {
fn as_str(&self) -> &'static str {
match self {
Self::Invoked => "invoke",
Self::Response => "response",
Self::Accept => "accept",
Self::Reject => "reject",
}
}
}
pub fn report_anthropic_event(
model: &Arc<dyn LanguageModel>,
event: AnthropicEventData,
cx: &gpui::App,
) {
let reporter = AnthropicEventReporter::new(model, cx);
reporter.report(event);
}
#[derive(Clone)]
pub struct AnthropicEventReporter {
http_client: Arc<dyn HttpClient>,
executor: BackgroundExecutor,
api_key: Option<String>,
is_anthropic: bool,
}
impl AnthropicEventReporter {
pub fn new(model: &Arc<dyn LanguageModel>, cx: &gpui::App) -> Self {
Self {
http_client: cx.http_client(),
executor: cx.background_executor().clone(),
api_key: model.api_key(cx),
is_anthropic: model.provider_id() == ANTHROPIC_PROVIDER_ID,
}
}
pub fn report(&self, event: AnthropicEventData) {
if !self.is_anthropic {
return;
}
let Some(api_key) = self.api_key.clone() else {
return;
};
let client = self.http_client.clone();
self.executor
.spawn(async move {
send_anthropic_event(event, client, api_key).await.log_err();
})
.detach();
}
}
async fn send_anthropic_event(
event: AnthropicEventData,
client: Arc<dyn HttpClient>,
api_key: String,
) -> anyhow::Result<()> {
let uri = format!("{ANTHROPIC_API_URL}/v1/log/zed");
let request_builder = HttpRequest::builder()
.method(Method::POST)
.uri(uri)
.header("X-Api-Key", api_key)
.header("Content-Type", "application/json");
let serialized_event = serde_json::json!({
"completion_type": event.completion_type.as_str(),
"event": event.event.as_str(),
"metadata": {
"language_name": event.language_name,
"message_id": event.message_id,
"platform": env::consts::OS,
}
});
let request = request_builder
.body(AsyncBody::from(serialized_event.to_string()))
.context("Failed to construct Anthropic telemetry HTTP request body")?;
let response = client
.send(request)
.await
.context("Failed to send telemetry HTTP request to Anthropic")?;
if response.status().is_success() {
Ok(())
} else {
Err(anyhow!(
"Anthropic telemetry logging failed with HTTP status: {}",
response.status()
))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,778 @@
use ai_onboarding::YoungAccountBanner;
use anyhow::Result;
use client::{Client, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls};
use cloud_api_client::LlmApiToken;
use cloud_api_types::OrganizationId;
use cloud_api_types::Plan;
use futures::FutureExt;
use futures::StreamExt;
use futures::future::BoxFuture;
use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt};
use language_model::{
AuthenticateError, IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, ZED_CLOUD_PROVIDER_ID,
ZED_CLOUD_PROVIDER_NAME,
};
use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider};
use release_channel::AppVersion;
use settings::SettingsStore;
pub use settings::ZedDotDevAvailableModel as AvailableModel;
pub use settings::ZedDotDevAvailableProvider as AvailableProvider;
use std::sync::Arc;
use ui::{TintColor, prelude::*};
const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = ZED_CLOUD_PROVIDER_NAME;
struct ClientTokenProvider {
client: Arc<Client>,
llm_api_token: LlmApiToken,
user_store: Entity<UserStore>,
}
impl CloudLlmTokenProvider for ClientTokenProvider {
type AuthContext = Option<OrganizationId>;
fn auth_context(&self, cx: &impl AppContext) -> Self::AuthContext {
self.user_store.read_with(cx, |user_store, _| {
user_store
.current_organization()
.map(|organization| organization.id.clone())
})
}
fn cached_token(
&self,
organization_id: Self::AuthContext,
) -> BoxFuture<'static, Result<String>> {
let client = self.client.clone();
let llm_api_token = self.llm_api_token.clone();
Box::pin(async move {
client
.cached_llm_token(&llm_api_token, organization_id)
.await
})
}
fn refresh_token(
&self,
organization_id: Self::AuthContext,
) -> BoxFuture<'static, Result<String>> {
let client = self.client.clone();
let llm_api_token = self.llm_api_token.clone();
Box::pin(async move {
client
.refresh_llm_token(&llm_api_token, organization_id)
.await
})
}
}
#[derive(Default, Clone, Debug, PartialEq)]
pub struct ZedDotDevSettings {
pub available_models: Vec<AvailableModel>,
}
pub struct CloudLanguageModelProvider {
state: Entity<State>,
_maintain_client_status: Task<()>,
}
pub struct State {
client: Arc<Client>,
user_store: Entity<UserStore>,
status: client::Status,
provider: Entity<CloudModelProvider<ClientTokenProvider>>,
_user_store_subscription: Subscription,
_settings_subscription: Subscription,
_llm_token_subscription: Subscription,
_provider_subscription: Subscription,
}
impl State {
fn new(
client: Arc<Client>,
user_store: Entity<UserStore>,
status: client::Status,
cx: &mut Context<Self>,
) -> Self {
let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
let token_provider = Arc::new(ClientTokenProvider {
client: client.clone(),
llm_api_token: global_llm_token(cx),
user_store: user_store.clone(),
});
let provider = cx.new(|cx| {
CloudModelProvider::new(
token_provider.clone(),
client.http_client(),
Some(AppVersion::global(cx)),
)
});
Self {
client: client.clone(),
user_store: user_store.clone(),
status,
_provider_subscription: cx.observe(&provider, |_, _, cx| cx.notify()),
provider,
_user_store_subscription: cx.subscribe(
&user_store,
move |this, _user_store, event, cx| match event {
client::user::Event::PrivateUserInfoUpdated => {
let status = *client.status().borrow();
if status.is_signed_out() {
return;
}
this.refresh_models(cx);
}
_ => {}
},
),
_settings_subscription: cx.observe_global::<SettingsStore>(|_, cx| {
cx.notify();
}),
_llm_token_subscription: cx.subscribe(
&refresh_llm_token_listener,
move |this, _listener, _event, cx| {
this.refresh_models(cx);
},
),
}
}
fn is_signed_out(&self, cx: &App) -> bool {
self.user_store.read(cx).current_user().is_none()
}
fn sign_in(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
let client = self.client.clone();
let mut current_user = self.user_store.read(cx).watch_current_user();
cx.spawn(async move |state, cx| {
client.sign_in_with_optional_connect(true, cx).await?;
while current_user.borrow().is_none() {
current_user.next().await;
}
state.update(cx, |_, cx| {
cx.notify();
})
})
}
fn refresh_models(&mut self, cx: &mut Context<Self>) {
self.provider.update(cx, |provider, cx| {
provider.refresh_models(cx).detach_and_log_err(cx);
});
}
}
impl CloudLanguageModelProvider {
pub fn new(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) -> Self {
let mut status_rx = client.status();
let status = *status_rx.borrow();
let state = cx.new(|cx| State::new(client.clone(), user_store.clone(), status, cx));
let state_ref = state.downgrade();
let maintain_client_status = cx.spawn(async move |cx| {
while let Some(status) = status_rx.next().await {
if let Some(this) = state_ref.upgrade() {
_ = this.update(cx, |this, cx| {
if this.status != status {
this.status = status;
cx.notify();
}
});
} else {
break;
}
}
});
Self {
state,
_maintain_client_status: maintain_client_status,
}
}
}
impl LanguageModelProviderState for CloudLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for CloudLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiZed)
}
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
let state = self.state.read(cx);
let provider = state.provider.read(cx);
let model = provider.default_model()?;
Some(provider.create_model(model))
}
fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
let state = self.state.read(cx);
let provider = state.provider.read(cx);
let model = provider.default_fast_model()?;
Some(provider.create_model(model))
}
fn recommended_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let state = self.state.read(cx);
let provider = state.provider.read(cx);
provider
.recommended_models()
.iter()
.map(|model| provider.create_model(model))
.collect()
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let state = self.state.read(cx);
let provider = state.provider.read(cx);
provider
.models()
.iter()
.map(|model| provider.create_model(model))
.collect()
}
fn is_authenticated(&self, cx: &App) -> bool {
let state = self.state.read(cx);
!state.is_signed_out(cx)
}
fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
if self.is_authenticated(cx) {
return Task::ready(Ok(()));
}
let mut status = self.state.read(cx).client.status();
let mut current_user = self.state.read(cx).user_store.read(cx).watch_current_user();
if !status.borrow().is_signing_in() {
return Task::ready(Ok(()));
}
cx.background_spawn(async move {
while status.borrow().is_signing_in() {
status.next().await;
}
while current_user.borrow().is_none() {
let current_status = *status.borrow();
if !matches!(
current_status,
client::Status::Authenticated
| client::Status::Reauthenticated
| client::Status::Connected { .. }
) {
return Err(AuthenticateError::Other(anyhow::anyhow!(
"sign-in did not complete: {current_status:?}"
)));
}
futures::select_biased! {
_ = current_user.next().fuse() => {},
_ = status.next().fuse() => {},
}
}
Ok(())
})
}
fn configuration_view(
&self,
_target_agent: language_model::ConfigurationViewTargetAgent,
_: &mut Window,
cx: &mut App,
) -> AnyView {
cx.new(|_| ConfigurationView::new(self.state.clone()))
.into()
}
fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
Task::ready(Ok(()))
}
}
#[derive(IntoElement, RegisterComponent)]
struct ZedAiConfiguration {
is_connected: bool,
plan: Option<Plan>,
is_zed_model_provider_enabled: bool,
eligible_for_trial: bool,
account_too_young: bool,
sign_in_callback: Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>,
}
impl RenderOnce for ZedAiConfiguration {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let (subscription_text, has_paid_plan) = match self.plan {
Some(Plan::ZedPro) => (
"You have access to Zed's hosted models through your Pro subscription.",
true,
),
Some(Plan::ZedProTrial) => (
"You have access to Zed's hosted models through your Pro trial.",
false,
),
Some(Plan::ZedStudent) => (
"You have access to Zed's hosted models through your Student subscription.",
true,
),
Some(Plan::ZedBusiness) => (
if self.is_zed_model_provider_enabled {
"You have access to Zed's hosted models through your organization."
} else {
"Zed's hosted models are disabled by your organization's configuration."
},
true,
),
Some(Plan::ZedFree) | None => (
if self.eligible_for_trial {
"Subscribe for access to Zed's hosted models. Start with a 14 day free trial."
} else {
"Subscribe for access to Zed's hosted models."
},
false,
),
};
let manage_subscription_buttons = if has_paid_plan {
Button::new("manage_settings", "Manage Subscription")
.full_width()
.label_size(LabelSize::Small)
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
.into_any_element()
} else if self.plan.is_none() || self.eligible_for_trial {
Button::new("start_trial", "Start 14-day Free Pro Trial")
.full_width()
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx)))
.into_any_element()
} else {
Button::new("upgrade", "Upgrade to Pro")
.full_width()
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)))
.into_any_element()
};
if !self.is_connected {
return v_flex()
.gap_2()
.child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models."))
.child(
Button::new("sign_in", "Sign In to use Zed AI")
.start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted))
.full_width()
.on_click({
let callback = self.sign_in_callback.clone();
move |_, window, cx| (callback)(window, cx)
}),
);
}
v_flex().gap_2().w_full().map(|this| {
if self.account_too_young {
this.child(YoungAccountBanner).child(
Button::new("upgrade", "Upgrade to Pro")
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.full_width()
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))),
)
} else {
this.text_sm()
.child(subscription_text)
.child(manage_subscription_buttons)
}
})
}
}
struct ConfigurationView {
state: Entity<State>,
sign_in_callback: Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>,
}
impl ConfigurationView {
fn new(state: Entity<State>) -> Self {
let sign_in_callback = Arc::new({
let state = state.clone();
move |_window: &mut Window, cx: &mut App| {
state.update(cx, |state, cx| {
state.sign_in(cx).detach_and_log_err(cx);
});
}
});
Self {
state,
sign_in_callback,
}
}
}
impl Render for ConfigurationView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.state.read(cx);
let user_store = state.user_store.read(cx);
let is_zed_model_provider_enabled = user_store
.current_organization_configuration()
.map_or(true, |config| config.is_zed_model_provider_enabled);
ZedAiConfiguration {
is_connected: !state.is_signed_out(cx),
plan: user_store.plan(),
is_zed_model_provider_enabled,
eligible_for_trial: user_store.trial_started_at().is_none(),
account_too_young: user_store.account_too_young(),
sign_in_callback: self.sign_in_callback.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use client::{Credentials, test::make_get_authenticated_user_response};
use clock::FakeSystemClock;
use feature_flags::FeatureFlagAppExt as _;
use gpui::TestAppContext;
use http_client::{FakeHttpClient, Method, Response};
use std::sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
const TEST_USER_ID: u64 = 42;
fn init_test(cx: &mut App) -> (Arc<Client>, Entity<UserStore>, CloudLanguageModelProvider) {
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,
);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
let provider = CloudLanguageModelProvider::new(user_store.clone(), client.clone(), cx);
(client, user_store, provider)
}
fn override_authenticate(
client: &Arc<Client>,
authenticate_rx: futures::channel::oneshot::Receiver<anyhow::Result<Credentials>>,
) {
let authenticate_rx = Arc::new(Mutex::new(Some(authenticate_rx)));
client.override_authenticate(move |cx| {
let authenticate_rx = authenticate_rx.clone();
cx.background_spawn(async move {
let authenticate_rx = authenticate_rx
.lock()
.expect("authenticate receiver lock poisoned")
.take()
.expect("authenticate receiver already used");
authenticate_rx.await?
})
});
}
fn respond_to_authenticated_user_after(
client: &Arc<Client>,
authenticated_user_rx: futures::channel::oneshot::Receiver<()>,
) {
let authenticated_user_rx = Arc::new(Mutex::new(Some(authenticated_user_rx)));
client
.http_client()
.as_fake()
.replace_handler(move |old_handler, request| {
let authenticated_user_rx = authenticated_user_rx.clone();
async move {
if request.method() == Method::GET && request.uri().path() == "/client/users/me"
{
let authenticated_user_rx = authenticated_user_rx
.lock()
.expect("authenticated user receiver lock poisoned")
.take();
if let Some(authenticated_user_rx) = authenticated_user_rx {
authenticated_user_rx.await.ok();
}
return Ok(Response::builder()
.status(200)
.body(
serde_json::to_string(&make_get_authenticated_user_response(
TEST_USER_ID as i32,
format!("user-{TEST_USER_ID}"),
))
.expect("failed to serialize authenticated user response")
.into(),
)
.expect("failed to build authenticated user response"));
}
old_handler(request).await
}
});
}
async fn sign_in_until_authenticating(
client: Arc<Client>,
cx: &mut TestAppContext,
) -> Task<anyhow::Result<Credentials>> {
let mut status = client.status();
let sign_in_task = cx.update(|cx| {
cx.spawn({
let client = client.clone();
async move |cx| client.sign_in(false, cx).await
})
});
while !status.borrow().is_signing_in() {
status.next().await;
}
sign_in_task
}
#[gpui::test]
async fn provider_authenticate_does_not_start_sign_in_when_signed_out(cx: &mut TestAppContext) {
let (client, _user_store, provider) = cx.update(init_test);
let authenticate_calls = Arc::new(AtomicUsize::new(0));
client.override_authenticate({
let authenticate_calls = authenticate_calls.clone();
move |_| {
authenticate_calls.fetch_add(1, Ordering::SeqCst);
Task::ready(Err(anyhow::anyhow!(
"provider authenticate should not start sign-in"
)))
}
});
assert!(!cx.read(|cx| provider.is_authenticated(cx)));
assert!(matches!(
*client.status().borrow(),
client::Status::SignedOut
));
cx.update(|cx| provider.authenticate(cx))
.now_or_never()
.expect("authenticate should return immediately when signed out")
.expect("authenticate should not fail when no sign-in is in progress");
cx.executor().run_until_parked();
assert_eq!(authenticate_calls.load(Ordering::SeqCst), 0);
assert!(matches!(
*client.status().borrow(),
client::Status::SignedOut
));
assert!(!cx.read(|cx| provider.is_authenticated(cx)));
}
#[gpui::test]
async fn provider_authenticate_waits_for_current_user(cx: &mut TestAppContext) {
let (client, _user_store, provider) = cx.update(init_test);
let (authenticate_tx, authenticate_rx) = futures::channel::oneshot::channel();
let (authenticated_user_tx, authenticated_user_rx) = futures::channel::oneshot::channel();
override_authenticate(&client, authenticate_rx);
respond_to_authenticated_user_after(&client, authenticated_user_rx);
let sign_in_task = sign_in_until_authenticating(client.clone(), cx).await;
let authenticate_task = cx.update(|cx| provider.authenticate(cx));
authenticate_tx
.send(Ok(Credentials {
user_id: TEST_USER_ID,
access_token: "token".to_string(),
}))
.expect("authenticate receiver dropped");
cx.executor().run_until_parked();
assert!(!cx.read(|cx| provider.is_authenticated(cx)));
authenticated_user_tx
.send(())
.expect("authenticated user receiver dropped");
sign_in_task
.await
.expect("sign-in should complete after user response");
authenticate_task
.await
.expect("provider authentication should complete after current user is populated");
assert!(cx.read(|cx| provider.is_authenticated(cx)));
cx.update(|cx| provider.authenticate(cx))
.now_or_never()
.expect("already-authenticated provider should authenticate immediately")
.unwrap();
}
#[gpui::test]
async fn provider_authenticate_returns_error_when_sign_in_fails(cx: &mut TestAppContext) {
let (client, _user_store, provider) = cx.update(init_test);
let (authenticate_tx, authenticate_rx) = futures::channel::oneshot::channel();
override_authenticate(&client, authenticate_rx);
let sign_in_task = sign_in_until_authenticating(client.clone(), cx).await;
let authenticate_task = cx.update(|cx| provider.authenticate(cx));
authenticate_tx
.send(Err(anyhow::anyhow!("test authentication failed")))
.expect("authenticate receiver dropped");
sign_in_task
.await
.expect_err("sign-in should report authentication failure");
let error = authenticate_task
.await
.expect_err("provider authentication should fail when sign-in fails");
assert!(error.to_string().contains("AuthenticationError"));
}
}
impl Component for ZedAiConfiguration {
fn name() -> &'static str {
"AI Configuration Content"
}
fn sort_name() -> &'static str {
"AI Configuration Content"
}
fn scope() -> ComponentScope {
ComponentScope::Onboarding
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
struct PreviewConfiguration {
plan: Option<Plan>,
is_connected: bool,
is_zed_model_provider_enabled: bool,
eligible_for_trial: bool,
}
let configuration = |config: PreviewConfiguration| -> AnyElement {
ZedAiConfiguration {
is_connected: config.is_connected,
plan: config.plan,
is_zed_model_provider_enabled: config.is_zed_model_provider_enabled,
eligible_for_trial: config.eligible_for_trial,
account_too_young: false,
sign_in_callback: Arc::new(|_, _| {}),
}
.into_any_element()
};
Some(
v_flex()
.p_4()
.gap_4()
.children(vec![
single_example(
"Not connected",
configuration(PreviewConfiguration {
plan: None,
is_connected: false,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"Accept Terms of Service",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"No Plan - Not eligible for trial",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"No Plan - Eligible for trial",
configuration(PreviewConfiguration {
plan: None,
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Free Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedFree),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Zed Pro Trial Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedProTrial),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Zed Pro Plan",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedPro),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: true,
}),
),
single_example(
"Business Plan - Zed models enabled",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedBusiness),
is_connected: true,
is_zed_model_provider_enabled: true,
eligible_for_trial: false,
}),
),
single_example(
"Business Plan - Zed models disabled",
configuration(PreviewConfiguration {
plan: Some(Plan::ZedBusiness),
is_connected: true,
is_zed_model_provider_enabled: false,
eligible_for_trial: false,
}),
),
])
.into_any_element(),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,755 @@
use anyhow::{Result, anyhow};
use collections::{HashMap, IndexMap};
use credentials_provider::CredentialsProvider;
use deepseek::DEEPSEEK_API_URL;
use futures::Stream;
use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
use http_client::HttpClient;
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
StopReason, TokenUsage, env_var,
};
pub use settings::DeepseekAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::sync::{Arc, LazyLock};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use language_model::util::{fix_streamed_json, parse_tool_arguments};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek");
const API_KEY_ENV_VAR_NAME: &str = "DEEPSEEK_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
#[derive(Default)]
struct RawToolCall {
id: String,
name: String,
arguments: String,
}
#[derive(Default, Clone, Debug, PartialEq)]
pub struct DeepSeekSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
pub struct DeepSeekLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
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 = DeepSeekLanguageModelProvider::api_url(cx);
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 = DeepSeekLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
)
}
}
impl DeepSeekLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let credentials_provider = this.credentials_provider.clone();
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
}
});
Self { http_client, state }
}
fn create_language_model(&self, model: deepseek::Model) -> Arc<dyn LanguageModel> {
Arc::new(DeepSeekLanguageModel {
id: LanguageModelId::from(model.id().to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
fn settings(cx: &App) -> &DeepSeekSettings {
&crate::AllLanguageModelSettings::get_global(cx).deepseek
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
DEEPSEEK_API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
}
impl LanguageModelProviderState for DeepSeekLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for DeepSeekLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiDeepSeek)
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(deepseek::Model::default()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(deepseek::Model::default_fast()))
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models = IndexMap::default();
models.insert("deepseek-v4-flash", deepseek::Model::V4Flash);
models.insert("deepseek-v4-pro", deepseek::Model::V4Pro);
for available_model in &Self::settings(cx).available_models {
models.insert(
&available_model.name,
deepseek::Model::Custom {
name: available_model.name.clone(),
display_name: available_model.display_name.clone(),
max_tokens: available_model.max_tokens,
max_output_tokens: available_model.max_output_tokens,
},
);
}
models
.into_values()
.map(|model| self.create_language_model(model))
.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| ConfigurationView::new(self.state.clone(), 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 DeepSeekLanguageModel {
id: LanguageModelId,
model: deepseek::Model,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl DeepSeekLanguageModel {
fn stream_completion(
&self,
request: deepseek::Request,
cx: &AsyncApp,
) -> BoxFuture<'static, Result<BoxStream<'static, Result<deepseek::StreamResponse>>>> {
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = DeepSeekLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey {
provider: PROVIDER_NAME,
});
};
let request =
deepseek::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
impl LanguageModel for DeepSeekLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(self.model.display_name().to_string())
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
true
}
fn supports_streaming_tools(&self) -> bool {
true
}
fn supports_thinking(&self) -> bool {
matches!(
self.model,
deepseek::Model::V4Flash | deepseek::Model::V4Pro
)
}
fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
if !self.supports_thinking() {
return Vec::new();
}
vec![
LanguageModelEffortLevel {
name: "High".into(),
value: "high".into(),
is_default: true,
},
LanguageModelEffortLevel {
name: "Max".into(),
value: "max".into(),
is_default: false,
},
]
}
fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
true
}
fn supports_images(&self) -> bool {
false
}
fn telemetry_id(&self) -> String {
format!("deepseek/{}", self.model.id())
}
fn max_token_count(&self) -> u64 {
self.model.max_token_count()
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens()
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
LanguageModelCompletionError,
>,
> {
let request = into_deepseek(request, &self.model, self.max_output_tokens());
let stream = self.stream_completion(request, cx);
async move {
let mapper = DeepSeekEventMapper::new();
Ok(mapper.map_stream(stream.await?).boxed())
}
.boxed()
}
}
pub fn into_deepseek(
request: LanguageModelRequest,
model: &deepseek::Model,
max_output_tokens: Option<u64>,
) -> deepseek::Request {
let thinking = deepseek_thinking(model, request.thinking_allowed);
let thinking_enabled = thinking
.as_ref()
.is_some_and(|thinking| thinking.kind == deepseek::ThinkingType::Enabled);
let mut messages = Vec::new();
let mut current_reasoning: Option<String> = None;
for message in request.messages {
for content in message.content {
match content {
MessageContent::Text(text) => {
let should_add = if message.role == Role::User {
!text.trim().is_empty()
} else {
!text.is_empty()
};
if should_add {
messages.push(match message.role {
Role::User => deepseek::RequestMessage::User { content: text },
Role::Assistant => deepseek::RequestMessage::Assistant {
content: Some(text),
tool_calls: Vec::new(),
reasoning_content: current_reasoning.take(),
},
Role::System => deepseek::RequestMessage::System { content: text },
});
}
}
MessageContent::Thinking { text, .. } => {
// Accumulate reasoning content for next assistant message
current_reasoning.get_or_insert_default().push_str(&text);
}
MessageContent::RedactedThinking(_) => {}
MessageContent::Image(_) => {}
MessageContent::ToolUse(tool_use) => {
let tool_call = deepseek::ToolCall {
id: tool_use.id.to_string(),
content: deepseek::ToolCallContent::Function {
function: deepseek::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
},
},
};
if let Some(deepseek::RequestMessage::Assistant { tool_calls, .. }) =
messages.last_mut()
{
tool_calls.push(tool_call);
} else {
messages.push(deepseek::RequestMessage::Assistant {
content: None,
tool_calls: vec![tool_call],
reasoning_content: current_reasoning.take(),
});
}
}
MessageContent::ToolResult(tool_result) => {
let mut text_parts: Vec<String> = Vec::new();
for part in &tool_result.content {
match part {
LanguageModelToolResultContent::Text(text) => {
text_parts.push(text.to_string());
}
LanguageModelToolResultContent::Image(_) => {
text_parts.push("[Tool responded with an image]".to_string());
}
}
}
let content = if text_parts.is_empty() {
"<Tool returned an empty string>".to_string()
} else {
text_parts.join("\n")
};
messages.push(deepseek::RequestMessage::Tool {
content,
tool_call_id: tool_result.tool_use_id.to_string(),
});
}
}
}
}
deepseek::Request {
model: model.id().to_string(),
messages,
stream: true,
max_tokens: max_output_tokens,
temperature: if thinking_enabled {
None
} else {
request.temperature
},
thinking,
reasoning_effort: if thinking_enabled {
into_deepseek_reasoning_effort(request.thinking_effort.as_deref())
} else {
None
},
response_format: None,
tool_choice: request.tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => deepseek::ToolChoice::Auto,
LanguageModelToolChoice::Any => deepseek::ToolChoice::Required,
LanguageModelToolChoice::None => deepseek::ToolChoice::None,
}),
tools: request
.tools
.into_iter()
.map(|tool| deepseek::ToolDefinition::Function {
function: deepseek::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
})
.collect(),
}
}
fn deepseek_thinking(
model: &deepseek::Model,
thinking_allowed: bool,
) -> Option<deepseek::Thinking> {
let kind = match model {
deepseek::Model::V4Flash | deepseek::Model::V4Pro => {
if thinking_allowed {
deepseek::ThinkingType::Enabled
} else {
deepseek::ThinkingType::Disabled
}
}
deepseek::Model::Custom { .. } => return None,
};
Some(deepseek::Thinking { kind })
}
fn into_deepseek_reasoning_effort(effort: Option<&str>) -> Option<deepseek::ReasoningEffort> {
match effort {
Some("high") => Some(deepseek::ReasoningEffort::High),
Some("max") => Some(deepseek::ReasoningEffort::Max),
_ => None,
}
}
pub struct DeepSeekEventMapper {
tool_calls_by_index: HashMap<usize, RawToolCall>,
}
impl DeepSeekEventMapper {
pub fn new() -> Self {
Self {
tool_calls_by_index: HashMap::default(),
}
}
pub fn map_stream(
mut self,
events: Pin<Box<dyn Send + Stream<Item = Result<deepseek::StreamResponse>>>>,
) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
{
events.flat_map(move |event| {
futures::stream::iter(match event {
Ok(event) => self.map_event(event),
Err(error) => vec![Err(LanguageModelCompletionError::from(error))],
})
})
}
pub fn map_event(
&mut self,
event: deepseek::StreamResponse,
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let Some(choice) = event.choices.first() else {
return vec![Err(LanguageModelCompletionError::from(anyhow!(
"Response contained no choices"
)))];
};
let mut events = Vec::new();
if let Some(content) = choice.delta.content.clone()
&& !content.is_empty()
{
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
}
if let Some(reasoning_content) = choice.delta.reasoning_content.clone() {
events.push(Ok(LanguageModelCompletionEvent::Thinking {
text: reasoning_content,
signature: None,
}));
}
if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
for tool_call in tool_calls {
let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
if let Some(tool_id) = tool_call.id.clone() {
entry.id = tool_id;
}
if let Some(function) = tool_call.function.as_ref() {
if let Some(name) = function.name.clone() {
entry.name = name;
}
if let Some(arguments) = function.arguments.clone() {
entry.arguments.push_str(&arguments);
}
}
if !entry.id.is_empty() && !entry.name.is_empty() {
if let Ok(input) = serde_json::from_str::<serde_json::Value>(
&fix_streamed_json(&entry.arguments),
) {
events.push(Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: entry.id.clone().into(),
name: entry.name.as_str().into(),
is_input_complete: false,
input,
raw_input: entry.arguments.clone(),
thought_signature: None,
},
)));
}
}
}
}
if let Some(usage) = event.usage {
events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})));
}
match choice.finish_reason.as_deref() {
Some("stop") => {
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
}
Some("tool_calls") => {
events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
match parse_tool_arguments(&tool_call.arguments) {
Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: tool_call.id.clone().into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input,
raw_input: tool_call.arguments.clone(),
thought_signature: None,
},
)),
Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
id: tool_call.id.clone().into(),
tool_name: tool_call.name.as_str().into(),
raw_input: tool_call.arguments.into(),
json_parse_error: error.to_string(),
}),
}
}));
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
}
Some(stop_reason) => {
log::error!("Unexpected DeepSeek stop_reason: {stop_reason:?}",);
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
}
None => {}
}
events
}
}
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, "sk-00000000000000000000000000000000"));
cx.observe(&state, |_, _, cx| {
cx.notify();
})
.detach();
let load_credentials_task = Some(cx.spawn({
let state = state.clone();
async move |this, cx| {
if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
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;
}
let state = self.state.clone();
cx.spawn(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, |editor, cx| editor.set_text("", window, cx));
let state = self.state.clone();
cx.spawn(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: &mut Context<Self>) -> bool {
!self.state.read(cx).is_authenticated()
}
}
impl Render for ConfigurationView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
} else {
let api_url = DeepSeekLanguageModelProvider::api_url(cx);
if api_url == DEEPSEEK_API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
if self.load_credentials_task.is_some() {
div()
.child(Label::new("Loading credentials..."))
.into_any_element()
} else if self.should_render_editor(cx) {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new("To use DeepSeek in Zed, you need an API key:"))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Get your API key from the"))
.child(ButtonLink::new(
"DeepSeek console",
"https://platform.deepseek.com/api_keys",
)),
)
.child(ListBulletItem::new(
"Paste your API key below and hit enter to start using the assistant",
)),
)
.child(self.api_key_editor.clone())
.child(
Label::new(format!(
"You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
))
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.into_any_element()
}
}
}

View File

@@ -0,0 +1,495 @@
use anyhow::{Context as _, Result};
use collections::BTreeMap;
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, StreamExt, future::BoxFuture};
pub use google_ai::completion::{GoogleEventMapper, into_google};
use google_ai::{GenerateContentResponse, GoogleModelMode};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
};
use language_model::{
GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelId,
LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, RateLimiter,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use settings::GoogleAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use language_model::ApiKeyState;
const PROVIDER_ID: LanguageModelProviderId = GOOGLE_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = GOOGLE_PROVIDER_NAME;
#[derive(Default, Clone, Debug, PartialEq)]
pub struct GoogleSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ModelMode {
#[default]
Default,
Thinking {
/// The maximum number of tokens to use for reasoning. Must be lower than the model's `max_output_tokens`.
budget_tokens: Option<u32>,
},
}
pub struct GoogleLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
credentials_provider: Arc<dyn CredentialsProvider>,
}
const GEMINI_API_KEY_VAR_NAME: &str = "GEMINI_API_KEY";
const GOOGLE_AI_API_KEY_VAR_NAME: &str = "GOOGLE_AI_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = LazyLock::new(|| {
// Try GEMINI_API_KEY first as primary, fallback to GOOGLE_AI_API_KEY
EnvVar::new(GEMINI_API_KEY_VAR_NAME.into()).or(EnvVar::new(GOOGLE_AI_API_KEY_VAR_NAME.into()))
});
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 = GoogleLanguageModelProvider::api_url(cx);
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 = GoogleLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
)
}
}
impl GoogleLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let credentials_provider = this.credentials_provider.clone();
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
}
});
Self { http_client, state }
}
fn create_language_model(&self, model: google_ai::Model) -> Arc<dyn LanguageModel> {
Arc::new(GoogleLanguageModel {
id: LanguageModelId::from(model.id().to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
fn settings(cx: &App) -> &GoogleSettings {
&crate::AllLanguageModelSettings::get_global(cx).google
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
google_ai::API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
}
impl LanguageModelProviderState for GoogleLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for GoogleLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiGoogle)
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(google_ai::Model::default()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(google_ai::Model::default_fast()))
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models = BTreeMap::default();
// Add base models from google_ai::Model::iter()
for model in google_ai::Model::iter() {
if !matches!(model, google_ai::Model::Custom { .. }) {
models.insert(model.id().to_string(), model);
}
}
// Override with available models from settings
for model in &GoogleLanguageModelProvider::settings(cx).available_models {
models.insert(
model.name.clone(),
google_ai::Model::Custom {
name: model.name.clone(),
display_name: model.display_name.clone(),
max_tokens: model.max_tokens,
mode: model.mode.unwrap_or_default(),
},
);
}
models
.into_values()
.map(|model| {
Arc::new(GoogleLanguageModel {
id: LanguageModelId::from(model.id().to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
}) as Arc<dyn LanguageModel>
})
.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| ConfigurationView::new(self.state.clone(), target_agent, 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 GoogleLanguageModel {
id: LanguageModelId,
model: google_ai::Model,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl GoogleLanguageModel {
fn stream_completion(
&self,
request: google_ai::GenerateContentRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<futures::stream::BoxStream<'static, Result<GenerateContentResponse>>>,
> {
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = GoogleLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
async move {
let api_key = api_key.context("Missing Google API key")?;
let request = google_ai::stream_generate_content(
http_client.as_ref(),
&api_url,
&api_key,
request,
);
request.await.context("failed to stream completion")
}
.boxed()
}
}
impl LanguageModel for GoogleLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(self.model.display_name().to_string())
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
self.model.supports_tools()
}
fn supports_images(&self) -> bool {
self.model.supports_images()
}
fn supports_thinking(&self) -> bool {
matches!(self.model.mode(), GoogleModelMode::Thinking { .. })
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto
| LanguageModelToolChoice::Any
| LanguageModelToolChoice::None => true,
}
}
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
LanguageModelToolSchemaFormat::JsonSchemaSubset
}
fn telemetry_id(&self) -> String {
format!("google/{}", self.model.request_id())
}
fn max_token_count(&self) -> u64 {
self.model.max_token_count()
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens()
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
let request = into_google(
request,
self.model.request_id().to_string(),
self.model.mode(),
);
let request = self.stream_completion(request, cx);
let future = self.request_limiter.stream(async move {
let response = request.await.map_err(LanguageModelCompletionError::from)?;
Ok(GoogleEventMapper::new().map_stream(response))
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
struct ConfigurationView {
api_key_editor: Entity<InputField>,
state: Entity<State>,
target_agent: language_model::ConfigurationViewTargetAgent,
load_credentials_task: Option<Task<()>>,
}
impl ConfigurationView {
fn new(
state: Entity<State>,
target_agent: language_model::ConfigurationViewTargetAgent,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
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: cx.new(|cx| InputField::new(window, cx, "AIzaSy...")),
target_agent,
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, |editor, cx| editor.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, |editor, cx| editor.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: &mut 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 env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!(
"API key set in {} environment variable",
API_KEY_ENV_VAR.name
)
} else {
let api_url = GoogleLanguageModelProvider::api_url(cx);
if api_url == google_ai::API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
if self.load_credentials_task.is_some() {
div()
.child(Label::new("Loading credentials..."))
.into_any_element()
} else if self.should_render_editor(cx) {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(),
ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
})))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey"))
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
)
)
.child(self.api_key_editor.clone())
.child(
Label::new(
format!("You can also set the {GEMINI_API_KEY_VAR_NAME} environment variable and restart Zed."),
)
.size(LabelSize::Small).color(Color::Muted),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.when(env_var_set, |this| {
this.tooltip_label(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset."))
})
.into_any_element()
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,706 @@
use anyhow::Result;
use collections::BTreeMap;
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
use http_client::HttpClient;
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID,
OPEN_AI_PROVIDER_NAME, RateLimiter, env_var,
};
use menu;
use open_ai::{
OPEN_AI_API_URL, ResponseStreamEvent,
responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response},
stream_completion,
};
use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
pub use open_ai::completion::{
OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response,
};
const PROVIDER_ID: LanguageModelProviderId = OPEN_AI_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = OPEN_AI_PROVIDER_NAME;
const API_KEY_ENV_VAR_NAME: &str = "OPENAI_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
#[derive(Default, Clone, Debug, PartialEq)]
pub struct OpenAiSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
pub struct OpenAiLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
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 = OpenAiLanguageModelProvider::api_url(cx);
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 = OpenAiLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
)
}
}
impl OpenAiLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let credentials_provider = this.credentials_provider.clone();
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
}
});
Self { http_client, state }
}
fn create_language_model(&self, model: open_ai::Model) -> Arc<dyn LanguageModel> {
Arc::new(OpenAiLanguageModel {
id: LanguageModelId::from(model.id().to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
fn settings(cx: &App) -> &OpenAiSettings {
&crate::AllLanguageModelSettings::get_global(cx).openai
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
open_ai::OPEN_AI_API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
}
impl LanguageModelProviderState for OpenAiLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for OpenAiLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiOpenAi)
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(open_ai::Model::default()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(open_ai::Model::default_fast()))
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models = BTreeMap::default();
// Add base models from open_ai::Model::iter()
for model in open_ai::Model::iter() {
if !matches!(model, open_ai::Model::Custom { .. }) {
models.insert(model.id().to_string(), model);
}
}
// Override with available models from settings
for model in &OpenAiLanguageModelProvider::settings(cx).available_models {
models.insert(
model.name.clone(),
open_ai::Model::Custom {
name: model.name.clone(),
display_name: model.display_name.clone(),
max_tokens: model.max_tokens,
max_output_tokens: model.max_output_tokens,
max_completion_tokens: model.max_completion_tokens,
reasoning_effort: model.reasoning_effort,
supports_chat_completions: model.capabilities.chat_completions,
supports_images: model.capabilities.images,
},
);
}
models
.into_values()
.map(|model| self.create_language_model(model))
.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| ConfigurationView::new(self.state.clone(), window, cx))
.into()
}
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
self.state
.update(cx, |state, cx| state.set_api_key(None, cx))
}
}
fn default_thinking_reasoning_effort(model: &open_ai::Model) -> Option<open_ai::ReasoningEffort> {
use open_ai::ReasoningEffort;
model
.reasoning_effort()
.filter(|effort| *effort != ReasoningEffort::None)
.or_else(|| {
let supported_efforts = model.supported_reasoning_efforts();
if supported_efforts.contains(&ReasoningEffort::Medium) {
Some(ReasoningEffort::Medium)
} else {
supported_efforts
.iter()
.copied()
.find(|effort| *effort != ReasoningEffort::None)
}
})
}
fn supports_selectable_thinking_effort(model: &open_ai::Model) -> bool {
model.uses_responses_api()
&& model
.supported_reasoning_efforts()
.iter()
.any(|effort| *effort != open_ai::ReasoningEffort::None)
}
fn supported_thinking_effort_levels(model: &open_ai::Model) -> Vec<LanguageModelEffortLevel> {
if !supports_selectable_thinking_effort(model) {
return Vec::new();
}
let default_effort = default_thinking_reasoning_effort(model);
model
.supported_reasoning_efforts()
.iter()
.copied()
.filter_map(|effort| {
let (name, value) = match effort {
open_ai::ReasoningEffort::None => return None,
open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"),
open_ai::ReasoningEffort::Low => ("Low", "low"),
open_ai::ReasoningEffort::Medium => ("Medium", "medium"),
open_ai::ReasoningEffort::High => ("High", "high"),
open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"),
};
Some(LanguageModelEffortLevel {
name: name.into(),
value: value.into(),
is_default: Some(effort) == default_effort,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_thinking_effort_levels_hide_none() {
let effort_levels = supported_thinking_effort_levels(&open_ai::Model::FivePointTwo);
let values = effort_levels
.iter()
.map(|level| level.value.as_ref())
.collect::<Vec<_>>();
assert_eq!(values, ["low", "medium", "high", "xhigh"]);
assert_eq!(
effort_levels
.iter()
.find(|level| level.is_default)
.map(|level| level.value.as_ref()),
Some("medium")
);
}
#[test]
fn models_supporting_only_none_have_no_selectable_thinking_effort() {
let model = open_ai::Model::Custom {
name: "custom-model".to_string(),
display_name: None,
max_tokens: 128_000,
max_output_tokens: None,
max_completion_tokens: None,
reasoning_effort: Some(open_ai::ReasoningEffort::None),
supports_chat_completions: false,
supports_images: true,
};
assert!(!supports_selectable_thinking_effort(&model));
assert!(supported_thinking_effort_levels(&model).is_empty());
assert!(
model
.supported_reasoning_efforts()
.contains(&open_ai::ReasoningEffort::None)
);
}
}
pub struct OpenAiLanguageModel {
id: LanguageModelId,
model: open_ai::Model,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl OpenAiLanguageModel {
fn stream_completion(
&self,
request: open_ai::Request,
cx: &AsyncApp,
) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
{
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = OpenAiLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
let future = self.request_limiter.stream(async move {
let provider = PROVIDER_NAME;
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
let request = stream_completion(
http_client.as_ref(),
provider.0.as_str(),
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
fn stream_response(
&self,
request: ResponseRequest,
cx: &AsyncApp,
) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponsesStreamEvent>>>>
{
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = OpenAiLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
let provider = PROVIDER_NAME;
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
let request = stream_response(
http_client.as_ref(),
provider.0.as_str(),
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
impl LanguageModel for OpenAiLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(self.model.display_name().to_string())
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
true
}
fn supports_images(&self) -> bool {
use open_ai::Model;
match &self.model {
Model::FourOmniMini
| Model::Five
| Model::FiveMini
| Model::FiveNano
| Model::FivePointOne
| Model::FivePointTwo
| Model::FivePointThreeCodex
| Model::FivePointFour
| Model::FivePointFourMini
| Model::FivePointFourNano
| Model::FivePointFourPro
| Model::FivePointFive
| Model::FivePointFivePro
| Model::O3 => true,
Model::Four => false,
Model::Custom {
supports_images, ..
} => *supports_images,
}
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto => true,
LanguageModelToolChoice::Any => true,
LanguageModelToolChoice::None => true,
}
}
fn supports_streaming_tools(&self) -> bool {
true
}
fn supports_thinking(&self) -> bool {
supports_selectable_thinking_effort(&self.model)
}
fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
supported_thinking_effort_levels(&self.model)
}
fn supports_split_token_display(&self) -> bool {
true
}
fn telemetry_id(&self) -> String {
format!("openai/{}", self.model.id())
}
fn max_token_count(&self) -> u64 {
self.model.max_token_count()
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens()
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
if self.model.uses_responses_api() {
let request = into_open_ai_response(
request,
self.model.id(),
self.model.supports_parallel_tool_calls(),
self.model.supports_prompt_cache_key(),
self.max_output_tokens(),
default_thinking_reasoning_effort(&self.model),
self.model
.supported_reasoning_efforts()
.contains(&open_ai::ReasoningEffort::None),
);
let completions = self.stream_response(request, cx);
async move {
let mapper = OpenAiResponseEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
} else {
let request = into_open_ai(
request,
self.model.id(),
self.model.supports_parallel_tool_calls(),
self.model.supports_prompt_cache_key(),
self.max_output_tokens(),
None,
false,
);
let completions = self.stream_completion(request, cx);
async move {
let mapper = OpenAiEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
}
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,
"sk-000000000000000000000000000000000000000000000000",
)
});
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, |editor, cx| editor.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: &mut 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 env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
} else {
let api_url = OpenAiLanguageModelProvider::api_url(cx);
if api_url == OPEN_AI_API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
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 OpenAI, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys"))
)
.child(
ListBulletItem::new("Ensure your OpenAI account has credits")
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
),
)
.child(self.api_key_editor.clone())
.child(
Label::new(format!(
"You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
))
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Label::new(
"Note that having a subscription for another service like GitHub Copilot won't work.",
)
.size(LabelSize::Small).color(Color::Muted),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.when(env_var_set, |this| {
this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
})
.into_any_element()
};
let compatible_api_section = h_flex()
.mt_1p5()
.gap_0p5()
.flex_wrap()
.when(self.should_render_editor(cx), |this| {
this.pt_1p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
})
.child(
h_flex()
.gap_2()
.child(
Icon::new(IconName::Info)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new("Zed also supports OpenAI-compatible models.")),
)
.child(
Button::new("docs", "Learn More")
.end_icon(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click(move |_, _window, cx| {
cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible")
}),
);
if self.load_credentials_task.is_some() {
div().child(Label::new("Loading credentials…")).into_any()
} else {
v_flex()
.size_full()
.child(api_key_section)
.child(compatible_api_section)
.into_any()
}
}
}

View File

@@ -0,0 +1,569 @@
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 http_client::HttpClient;
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
};
use menu;
use open_ai::{
ResponseStreamEvent,
responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response},
stream_completion,
};
use settings::{Settings, SettingsStore};
use std::sync::Arc;
use ui::{ElevationIndex, Tooltip, prelude::*};
use ui_input::InputField;
use util::ResultExt;
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;
#[derive(Default, Clone, Debug, PartialEq)]
pub struct OpenAiCompatibleSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
pub struct OpenAiCompatibleLanguageModelProvider {
id: LanguageModelProviderId,
name: LanguageModelProviderName,
http_client: Arc<dyn HttpClient>,
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>,
http_client: Arc<dyn HttpClient>,
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,
}
});
Self {
id: id.clone().into(),
name: id.into(),
http_client,
state,
}
}
fn create_language_model(&self, model: AvailableModel) -> Arc<dyn LanguageModel> {
Arc::new(OpenAiCompatibleLanguageModel {
id: LanguageModelId::from(model.name.clone()),
provider_id: self.id.clone(),
provider_name: self.name.clone(),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
}
impl LanguageModelProviderState for OpenAiCompatibleLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
self.id.clone()
}
fn name(&self) -> LanguageModelProviderName {
self.name.clone()
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiOpenAiCompat)
}
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| ConfigurationView::new(self.state.clone(), 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 OpenAiCompatibleLanguageModel {
id: LanguageModelId,
provider_id: LanguageModelProviderId,
provider_name: LanguageModelProviderName,
model: AvailableModel,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl OpenAiCompatibleLanguageModel {
fn stream_completion(
&self,
request: open_ai::Request,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>,
LanguageModelCompletionError,
>,
> {
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, _cx| {
let api_url = &state.settings.api_url;
(
state.api_key_state.key(api_url),
state.settings.api_url.clone(),
)
});
let provider = self.provider_name.clone();
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
let request = stream_completion(
http_client.as_ref(),
provider.0.as_str(),
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
fn stream_response(
&self,
request: ResponseRequest,
cx: &AsyncApp,
) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponsesStreamEvent>>>>
{
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, _cx| {
let api_url = &state.settings.api_url;
(
state.api_key_state.key(api_url),
state.settings.api_url.clone(),
)
});
let provider = self.provider_name.clone();
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
let request = stream_response(
http_client.as_ref(),
provider.0.as_str(),
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
impl LanguageModel for OpenAiCompatibleLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(
self.model
.display_name
.clone()
.unwrap_or_else(|| self.model.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.model.capabilities.tools
}
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
LanguageModelToolSchemaFormat::JsonSchemaSubset
}
fn supports_images(&self) -> bool {
self.model.capabilities.images
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto => self.model.capabilities.tools,
LanguageModelToolChoice::Any => self.model.capabilities.tools,
LanguageModelToolChoice::None => true,
}
}
fn supports_streaming_tools(&self) -> bool {
true
}
fn supports_split_token_display(&self) -> bool {
true
}
fn telemetry_id(&self) -> String {
format!("openai/{}", self.model.name)
}
fn max_token_count(&self) -> u64 {
self.model.max_tokens
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
if self.model.capabilities.chat_completions {
let request = into_open_ai(
request,
&self.model.name,
self.model.capabilities.parallel_tool_calls,
self.model.capabilities.prompt_cache_key,
self.max_output_tokens(),
self.model.reasoning_effort,
self.model.capabilities.interleaved_reasoning,
);
let completions = self.stream_completion(request, cx);
async move {
let mapper = OpenAiEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
} else {
let request = into_open_ai_response(
request,
&self.model.name,
self.model.capabilities.parallel_tool_calls,
self.model.capabilities.prompt_cache_key,
self.max_output_tokens(),
self.model
.reasoning_effort
.filter(|effort| *effort != open_ai::ReasoningEffort::None),
self.model.reasoning_effort == Some(open_ai::ReasoningEffort::None),
);
let completions = self.stream_response(request, cx);
async move {
let mapper = OpenAiResponseEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
}
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()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,943 @@
use anyhow::Result;
use collections::BTreeMap;
use credentials_provider::CredentialsProvider;
use fs::Fs;
use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
use http_client::{AsyncBody, HttpClient, http};
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
ReasoningEffort, env_var,
};
use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription};
pub use settings::OpenCodeAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore, update_settings_file};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{
Banner, ButtonLink, ConfiguredApiCard, List, ListBulletItem, Severity, Switch,
SwitchLabelPosition, ToggleState, prelude::*,
};
use ui_input::InputField;
use util::ResultExt;
use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic};
use crate::provider::google::{GoogleEventMapper, into_google};
use crate::provider::open_ai::{
OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response,
};
fn normalize_reasoning_effort(effort: &str) -> Option<ReasoningEffort> {
match effort.trim().to_ascii_lowercase().as_str() {
"none" => Some(ReasoningEffort::None),
"minimal" => Some(ReasoningEffort::Minimal),
"low" => Some(ReasoningEffort::Low),
"medium" => Some(ReasoningEffort::Medium),
"high" => Some(ReasoningEffort::High),
"max" | "xhigh" => Some(ReasoningEffort::XHigh),
_ => None,
}
}
fn reasoning_effort_display(effort: ReasoningEffort) -> (&'static str, &'static str) {
match effort {
ReasoningEffort::None => ("None", "none"),
ReasoningEffort::Minimal => ("Minimal", "minimal"),
ReasoningEffort::Low => ("Low", "low"),
ReasoningEffort::Medium => ("Medium", "medium"),
ReasoningEffort::High => ("High", "high"),
ReasoningEffort::XHigh => ("Max", "max"),
}
}
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opencode");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenCode");
const API_KEY_ENV_VAR_NAME: &str = "OPENCODE_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
#[derive(Default, Clone, Debug, PartialEq)]
pub struct OpenCodeSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
pub show_zen_models: bool,
pub show_go_models: bool,
pub show_free_models: bool,
}
pub struct OpenCodeLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
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 = OpenCodeLanguageModelProvider::api_url(cx);
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 = OpenCodeLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
)
}
}
impl OpenCodeLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let credentials_provider = this.credentials_provider.clone();
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
}
});
Self { http_client, state }
}
fn create_language_model(
&self,
model: opencode::Model,
subscription: OpenCodeSubscription,
) -> Arc<dyn LanguageModel> {
let id_str = format!("{}/{}", subscription.id_prefix(), model.id());
Arc::new(OpenCodeLanguageModel {
id: LanguageModelId::from(id_str),
model,
subscription,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
pub fn settings(cx: &App) -> &OpenCodeSettings {
&crate::AllLanguageModelSettings::get_global(cx).opencode
}
fn subscription_enabled(subscription: OpenCodeSubscription, cx: &App) -> bool {
let settings = Self::settings(cx);
match subscription {
OpenCodeSubscription::Zen => settings.show_zen_models,
OpenCodeSubscription::Go => settings.show_go_models,
OpenCodeSubscription::Free => settings.show_free_models,
}
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
OPENCODE_API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
}
impl LanguageModelProviderState for OpenCodeLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for OpenCodeLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiOpenCode)
}
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
if Self::subscription_enabled(OpenCodeSubscription::Go, cx) {
// If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go
Some(
self.create_language_model(opencode::Model::default_go(), OpenCodeSubscription::Go),
)
} else if Self::subscription_enabled(OpenCodeSubscription::Zen, cx) {
Some(self.create_language_model(opencode::Model::default(), OpenCodeSubscription::Zen))
} else if Self::subscription_enabled(OpenCodeSubscription::Free, cx) {
Some(
self.create_language_model(
opencode::Model::default_free(),
OpenCodeSubscription::Free,
),
)
} else {
None
}
}
fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
if Self::subscription_enabled(OpenCodeSubscription::Go, cx) {
// If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go
Some(self.create_language_model(
opencode::Model::default_go_fast(),
OpenCodeSubscription::Go,
))
} else if Self::subscription_enabled(OpenCodeSubscription::Zen, cx) {
Some(
self.create_language_model(
opencode::Model::default_fast(),
OpenCodeSubscription::Zen,
),
)
} else if Self::subscription_enabled(OpenCodeSubscription::Free, cx) {
Some(self.create_language_model(
opencode::Model::default_free_fast(),
OpenCodeSubscription::Free,
))
} else {
None
}
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models: BTreeMap<String, (opencode::Model, OpenCodeSubscription)> =
BTreeMap::default();
let settings = Self::settings(cx);
for model in opencode::Model::iter() {
if matches!(model, opencode::Model::Custom { .. }) {
continue;
}
for &subscription in model.available_subscriptions() {
if Self::subscription_enabled(subscription, cx) {
let key = format!("{}/{}", subscription.id_prefix(), model.id());
models.insert(key, (model.clone(), subscription));
}
}
}
for model in &settings.available_models {
let protocol = match model.protocol.as_str() {
"anthropic" => ApiProtocol::Anthropic,
"openai_responses" => ApiProtocol::OpenAiResponses,
"openai_chat" => ApiProtocol::OpenAiChat,
"google" => ApiProtocol::Google,
_ => ApiProtocol::OpenAiChat, // default fallback
};
let subscription = match model.subscription {
Some(settings::OpenCodeModelSubscription::Go) => OpenCodeSubscription::Go,
Some(settings::OpenCodeModelSubscription::Free) => OpenCodeSubscription::Free,
Some(settings::OpenCodeModelSubscription::Zen) | None => OpenCodeSubscription::Zen,
};
if !Self::subscription_enabled(subscription, cx) {
continue;
}
let custom_model = opencode::Model::Custom {
name: model.name.clone(),
display_name: model.display_name.clone(),
max_tokens: model.max_tokens,
max_output_tokens: model.max_output_tokens,
protocol,
reasoning_effort_levels: model.reasoning_effort_levels.clone(),
custom_model_api_url: model.custom_model_api_url.clone(),
interleaved_reasoning: model.interleaved_reasoning,
};
let key = format!("{}/{}", subscription.id_prefix(), model.name);
models.insert(key, (custom_model, subscription));
}
models
.into_values()
.map(|(model, subscription)| self.create_language_model(model, subscription))
.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| ConfigurationView::new(self.state.clone(), 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 OpenCodeLanguageModel {
id: LanguageModelId,
model: opencode::Model,
subscription: OpenCodeSubscription,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
struct InjectHeaderClient {
inner: Arc<dyn HttpClient>,
name: http::HeaderName,
value: http::HeaderValue,
}
impl HttpClient for InjectHeaderClient {
fn user_agent(&self) -> Option<&http::HeaderValue> {
self.inner.user_agent()
}
fn proxy(&self) -> Option<&http_client::Url> {
self.inner.proxy()
}
fn send(
&self,
mut req: http::Request<AsyncBody>,
) -> futures::future::BoxFuture<'static, anyhow::Result<http::Response<AsyncBody>>> {
req.headers_mut()
.insert(self.name.clone(), self.value.clone());
self.inner.send(req)
}
}
impl OpenCodeLanguageModel {
fn base_api_url(&self, cx: &AsyncApp) -> SharedString {
// Custom models can override the API URL
if let opencode::Model::Custom {
custom_model_api_url: Some(url),
..
} = &self.model
{
if !url.is_empty() {
return url.clone().into();
}
}
// Combine base URL with subscription path suffix
let base = self
.state
.read_with(cx, |_, cx| OpenCodeLanguageModelProvider::api_url(cx));
let suffix = self.subscription.api_path_suffix();
let base_str = base.as_ref().trim_end_matches('/');
format!("{}{}", base_str, suffix).into()
}
fn api_key(&self, cx: &AsyncApp) -> Option<Arc<str>> {
self.state.read_with(cx, |state, cx| {
let api_url = OpenCodeLanguageModelProvider::api_url(cx);
state.api_key_state.key(&api_url)
})
}
fn stream_anthropic(
&self,
request: anthropic::Request,
http_client: Arc<dyn HttpClient>,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<anthropic::Event, anthropic::AnthropicError>,
>,
LanguageModelCompletionError,
>,
> {
// Anthropic crate appends /v1/messages to api_url
let api_url = self.base_api_url(cx);
let api_key = self.api_key(cx);
let future = self.request_limiter.stream(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,
None,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
fn stream_openai_chat(
&self,
request: open_ai::Request,
http_client: Arc<dyn HttpClient>,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<futures::stream::BoxStream<'static, Result<open_ai::ResponseStreamEvent>>>,
> {
// OpenAI crate appends /chat/completions to api_url, so we pass base + "/v1"
let base_url = self.base_api_url(cx);
let api_url: SharedString = format!("{base_url}/v1").into();
let api_key = self.api_key(cx);
let provider_name = PROVIDER_NAME.0.to_string();
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey {
provider: PROVIDER_NAME,
});
};
let request = open_ai::stream_completion(
http_client.as_ref(),
&provider_name,
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
fn stream_openai_response(
&self,
request: open_ai::responses::Request,
http_client: Arc<dyn HttpClient>,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<futures::stream::BoxStream<'static, Result<open_ai::responses::StreamEvent>>>,
> {
// Responses crate appends /responses to api_url, so we pass base + "/v1"
let base_url = self.base_api_url(cx);
let api_url: SharedString = format!("{base_url}/v1").into();
let api_key = self.api_key(cx);
let provider_name = PROVIDER_NAME.0.to_string();
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey {
provider: PROVIDER_NAME,
});
};
let request = open_ai::responses::stream_response(
http_client.as_ref(),
&provider_name,
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
fn stream_google(
&self,
request: google_ai::GenerateContentRequest,
http_client: Arc<dyn HttpClient>,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<futures::stream::BoxStream<'static, Result<google_ai::GenerateContentResponse>>>,
> {
let api_url = self.base_api_url(cx);
let api_key = self.api_key(cx);
let future = self.request_limiter.stream(async move {
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey {
provider: PROVIDER_NAME,
});
};
let request = opencode::stream_generate_content(
http_client.as_ref(),
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
impl LanguageModel for OpenCodeLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(format!(
"{}: {}",
self.subscription.display_name(),
self.model.display_name()
))
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
self.model.supports_tools()
}
fn supports_images(&self) -> bool {
self.model.supports_images()
}
fn supports_thinking(&self) -> bool {
self.model
.supported_reasoning_effort_levels()
.is_some_and(|levels| levels.iter().any(|effort| *effort != ReasoningEffort::None))
}
fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
self.model
.supported_reasoning_effort_levels()
.map(|levels| {
let levels = levels
.into_iter()
.filter(|effort| *effort != ReasoningEffort::None)
.collect::<Vec<_>>();
if levels.is_empty() {
return Vec::new();
}
let default_index = levels.len() - 1;
levels
.into_iter()
.enumerate()
.map(|(i, effort)| {
let (name, value) = reasoning_effort_display(effort);
LanguageModelEffortLevel {
name: name.into(),
value: value.into(),
is_default: i == default_index,
}
})
.collect()
})
.unwrap_or_default()
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => true,
LanguageModelToolChoice::None => {
// Google models don't support None tool choice
self.model.protocol(self.subscription) != ApiProtocol::Google
}
}
}
fn telemetry_id(&self) -> String {
format!(
"opencode/{}/{}",
self.subscription.id_prefix(),
self.model.id()
)
}
fn max_token_count(&self) -> u64 {
self.model.max_token_count()
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens()
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
let http_client = if let Some(ref thread_id) = request.thread_id
&& let Ok(value) = http::HeaderValue::from_str(thread_id)
{
Arc::new(InjectHeaderClient {
inner: self.http_client.clone(),
name: http::HeaderName::from_static("x-opencode-session"),
value,
})
} else {
self.http_client.clone()
};
match self.model.protocol(self.subscription) {
ApiProtocol::Anthropic => {
let mode = if self.supports_thinking() && request.thinking_allowed {
anthropic::AnthropicModelMode::AdaptiveThinking
} else {
anthropic::AnthropicModelMode::Default
};
let anthropic_request = into_anthropic(
request,
self.model.id().to_string(),
1.0,
self.model.max_output_tokens().unwrap_or(8192),
mode,
anthropic::completion::AnthropicPromptCacheMode::Automatic,
);
let stream = self.stream_anthropic(anthropic_request, http_client, cx);
async move {
let mapper = AnthropicEventMapper::new();
Ok(mapper.map_stream(stream.await?).boxed())
}
.boxed()
}
ApiProtocol::OpenAiChat => {
let reasoning_effort = if request.thinking_allowed {
request
.thinking_effort
.as_deref()
.and_then(normalize_reasoning_effort)
} else {
None
};
let openai_request = into_open_ai(
request,
self.model.id(),
false,
false,
self.model.max_output_tokens(),
reasoning_effort,
self.model.interleaved_reasoning(),
);
let stream = self.stream_openai_chat(openai_request, http_client, cx);
async move {
let mapper = OpenAiEventMapper::new();
Ok(mapper.map_stream(stream.await?).boxed())
}
.boxed()
}
ApiProtocol::OpenAiResponses => {
let supports_none_reasoning_effort = self
.model
.supported_reasoning_effort_levels()
.is_some_and(|levels| levels.contains(&ReasoningEffort::None));
let response_request = into_open_ai_response(
request,
self.model.id(),
false,
false,
self.model.max_output_tokens(),
None,
supports_none_reasoning_effort,
);
let stream = self.stream_openai_response(response_request, http_client, cx);
async move {
let mapper = OpenAiResponseEventMapper::new();
Ok(mapper.map_stream(stream.await?).boxed())
}
.boxed()
}
ApiProtocol::Google => {
let google_request = into_google(
request,
self.model.id().to_string(),
google_ai::GoogleModelMode::Default,
);
let stream = self.stream_google(google_request, http_client, cx);
async move {
let mapper = GoogleEventMapper::new();
Ok(mapper.map_stream(stream.await?.boxed()).boxed())
}
.boxed()
}
}
}
}
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, "sk-00000000000000000000000000000000").label("API key")
});
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))) {
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;
}
self.api_key_editor
.update(cx, |editor, cx| editor.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, |editor, cx| editor.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 set_subscription_enabled(
&mut self,
subscription: OpenCodeSubscription,
is_enabled: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
let opencode_settings = settings
.language_models
.get_or_insert_default()
.opencode
.get_or_insert_default();
match subscription {
OpenCodeSubscription::Zen => opencode_settings.show_zen_models = Some(is_enabled),
OpenCodeSubscription::Go => opencode_settings.show_go_models = Some(is_enabled),
OpenCodeSubscription::Free => opencode_settings.show_free_models = Some(is_enabled),
}
});
}
fn should_render_editor(&self, cx: &mut 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 env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
} else {
let api_url = OpenCodeLanguageModelProvider::api_url(cx);
if api_url == OPENCODE_API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
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 OpenCode models in Zed, you need an API key:",
))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Sign in and get your key at"))
.child(ButtonLink::new(
"OpenCode Console",
"https://opencode.ai/auth",
)),
)
.child(ListBulletItem::new(
"Paste your API key below and hit enter to start using OpenCode",
)),
)
.child(self.api_key_editor.clone())
.child(
Label::new(format!(
"You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
))
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.when(env_var_set, |this| {
this.tooltip_label(format!(
"To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."
))
})
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.into_any_element()
};
if self.load_credentials_task.is_some() {
div().child(Label::new("Loading credentials...")).into_any()
} else {
let settings = OpenCodeLanguageModelProvider::settings(cx);
let show_zen = settings.show_zen_models;
let show_go = settings.show_go_models;
let show_free = settings.show_free_models;
let subscription_toggles = v_flex()
.gap_1()
.child(Label::new("Subscriptions:").color(Color::Muted))
.child(
Switch::new("opencode-show-zen-models", show_zen.into())
.label("Show Zen models")
.label_position(SwitchLabelPosition::End)
.on_click(cx.listener(|this, state, window, cx| {
this.set_subscription_enabled(
OpenCodeSubscription::Zen,
matches!(state, ToggleState::Selected),
window,
cx,
);
})),
)
.child(
Switch::new("opencode-show-go-models", show_go.into())
.label("Show Go models")
.label_position(SwitchLabelPosition::End)
.on_click(cx.listener(|this, state, window, cx| {
this.set_subscription_enabled(
OpenCodeSubscription::Go,
matches!(state, ToggleState::Selected),
window,
cx,
);
})),
)
.child(
Switch::new("opencode-show-free-models", show_free.into())
.label("Show Free models")
.label_position(SwitchLabelPosition::End)
.on_click(cx.listener(|this, state, window, cx| {
this.set_subscription_enabled(
OpenCodeSubscription::Free,
matches!(state, ToggleState::Selected),
window,
cx,
);
})),
);
let no_subscriptions_warning = if !show_zen && !show_go && !show_free {
Some(Banner::new().severity(Severity::Warning).child(Label::new(
"No subscriptions enabled. Enable at least one subscription to use OpenCode.",
)))
} else {
None
};
v_flex()
.size_full()
.gap_2()
.child(api_key_section)
.child(subscription_toggles)
.children(no_subscriptions_warning)
.into_any()
}
}
}

View File

@@ -0,0 +1,711 @@
use anyhow::Result;
use collections::BTreeMap;
use credentials_provider::CredentialsProvider;
use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http};
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
env_var,
};
use open_ai::ResponseStreamEvent;
use serde::Deserialize;
pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
pub use settings::VercelAiGatewayAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel_ai_gateway");
const PROVIDER_NAME: LanguageModelProviderName =
LanguageModelProviderName::new("Vercel AI Gateway");
const API_URL: &str = "https://ai-gateway.vercel.sh/v1";
const API_KEY_ENV_VAR_NAME: &str = "VERCEL_AI_GATEWAY_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
#[derive(Default, Clone, Debug, PartialEq)]
pub struct VercelAiGatewaySettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
pub struct VercelAiGatewayLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
credentials_provider: Arc<dyn CredentialsProvider>,
http_client: Arc<dyn HttpClient>,
available_models: Vec<AvailableModel>,
fetch_models_task: Option<Task<Result<(), LanguageModelCompletionError>>>,
}
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 = VercelAiGatewayLanguageModelProvider::api_url(cx);
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 = VercelAiGatewayLanguageModelProvider::api_url(cx);
let task = self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| this.restart_fetch_models_task(cx))
.ok();
result
})
}
fn fetch_models(
&mut self,
cx: &mut Context<Self>,
) -> Task<Result<(), LanguageModelCompletionError>> {
let http_client = self.http_client.clone();
let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx);
let api_key = self.api_key_state.key(&api_url);
cx.spawn(async move |this, cx| {
let models = list_models(http_client.as_ref(), &api_url, api_key.as_deref()).await?;
this.update(cx, |this, cx| {
this.available_models = models;
cx.notify();
})
.map_err(|e| LanguageModelCompletionError::Other(e))?;
Ok(())
})
}
fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
if self.is_authenticated() {
let task = self.fetch_models(cx);
self.fetch_models_task.replace(task);
} else {
self.available_models = Vec::new();
}
}
}
impl VercelAiGatewayLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>({
let mut last_settings = VercelAiGatewayLanguageModelProvider::settings(cx).clone();
move |this: &mut State, cx| {
let current_settings = VercelAiGatewayLanguageModelProvider::settings(cx);
if current_settings != &last_settings {
last_settings = current_settings.clone();
this.authenticate(cx).detach();
cx.notify();
}
}
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
http_client: http_client.clone(),
available_models: Vec::new(),
fetch_models_task: None,
}
});
Self { http_client, state }
}
fn settings(cx: &App) -> &VercelAiGatewaySettings {
&crate::AllLanguageModelSettings::get_global(cx).vercel_ai_gateway
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
fn default_available_model() -> AvailableModel {
AvailableModel {
name: "openai/gpt-5.3-codex".to_string(),
display_name: Some("GPT 5.3 Codex".to_string()),
max_tokens: 400_000,
max_output_tokens: Some(128_000),
max_completion_tokens: None,
capabilities: ModelCapabilities::default(),
}
}
fn create_language_model(&self, model: AvailableModel) -> Arc<dyn LanguageModel> {
Arc::new(VercelAiGatewayLanguageModel {
id: LanguageModelId::from(model.name.clone()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
}
impl LanguageModelProviderState for VercelAiGatewayLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for VercelAiGatewayLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiVercel)
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(Self::default_available_model()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
None
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models = BTreeMap::default();
let default_model = Self::default_available_model();
models.insert(default_model.name.clone(), default_model);
for model in self.state.read(cx).available_models.clone() {
models.insert(model.name.clone(), model);
}
for model in &Self::settings(cx).available_models {
models.insert(model.name.clone(), model.clone());
}
models
.into_values()
.map(|model| self.create_language_model(model))
.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| ConfigurationView::new(self.state.clone(), 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 VercelAiGatewayLanguageModel {
id: LanguageModelId,
model: AvailableModel,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl VercelAiGatewayLanguageModel {
fn stream_open_ai(
&self,
request: open_ai::Request,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>,
LanguageModelCompletionError,
>,
> {
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
let future = self.request_limiter.stream(async move {
let provider = PROVIDER_NAME;
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
let request = open_ai::stream_completion(
http_client.as_ref(),
provider.0.as_str(),
&api_url,
&api_key,
request,
);
let response = request.await.map_err(map_open_ai_error)?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
fn map_open_ai_error(error: open_ai::RequestError) -> LanguageModelCompletionError {
match error {
open_ai::RequestError::HttpResponseError {
status_code,
body,
headers,
..
} => {
let retry_after = headers
.get(http::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok()?.parse::<u64>().ok())
.map(std::time::Duration::from_secs);
LanguageModelCompletionError::from_http_status(
PROVIDER_NAME,
status_code,
extract_error_message(&body),
retry_after,
)
}
open_ai::RequestError::Other(error) => LanguageModelCompletionError::Other(error),
}
}
fn extract_error_message(body: &str) -> String {
let json = match serde_json::from_str::<serde_json::Value>(body) {
Ok(json) => json,
Err(_) => return body.to_string(),
};
let message = json
.get("error")
.and_then(|value| {
value
.get("message")
.and_then(serde_json::Value::as_str)
.or_else(|| value.as_str())
})
.or_else(|| json.get("message").and_then(serde_json::Value::as_str))
.map(ToString::to_string)
.unwrap_or_else(|| body.to_string());
clean_error_message(&message)
}
fn clean_error_message(message: &str) -> String {
let lower = message.to_lowercase();
if lower.contains("vercel_oidc_token") && lower.contains("oidc token") {
return "Authentication failed for Vercel AI Gateway. Use a Vercel AI Gateway key (vck_...).\nCreate or manage keys in Vercel AI Gateway console.\nIf this persists, regenerate the key and update it in Vercel AI Gateway provider settings in Zed.".to_string();
}
if lower.contains("invalid api key") || lower.contains("invalid_api_key") {
return "Authentication failed for Vercel AI Gateway. Check that your Vercel AI Gateway key starts with vck_ and is active.".to_string();
}
message.to_string()
}
fn has_tag(tags: &[String], expected: &str) -> bool {
tags.iter()
.any(|tag| tag.trim().eq_ignore_ascii_case(expected))
}
impl LanguageModel for VercelAiGatewayLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(
self.model
.display_name
.clone()
.unwrap_or_else(|| self.model.name.clone()),
)
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
self.model.capabilities.tools
}
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
LanguageModelToolSchemaFormat::JsonSchemaSubset
}
fn supports_images(&self) -> bool {
self.model.capabilities.images
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto => self.model.capabilities.tools,
LanguageModelToolChoice::Any => self.model.capabilities.tools,
LanguageModelToolChoice::None => true,
}
}
fn supports_streaming_tools(&self) -> bool {
true
}
fn supports_split_token_display(&self) -> bool {
true
}
fn telemetry_id(&self) -> String {
format!("vercel_ai_gateway/{}", self.model.name)
}
fn max_token_count(&self) -> u64 {
self.model.max_tokens
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
let request = crate::provider::open_ai::into_open_ai(
request,
&self.model.name,
self.model.capabilities.parallel_tool_calls,
self.model.capabilities.prompt_cache_key,
self.max_output_tokens(),
None,
false,
);
let completions = self.stream_open_ai(request, cx);
async move {
let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ApiModel>,
}
#[derive(Deserialize)]
struct ApiModel {
id: String,
name: Option<String>,
context_window: Option<u64>,
max_tokens: Option<u64>,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
supported_parameters: Vec<String>,
#[serde(default)]
tags: Vec<String>,
architecture: Option<ApiModelArchitecture>,
}
#[derive(Deserialize)]
struct ApiModelArchitecture {
#[serde(default)]
input_modalities: Vec<String>,
}
async fn list_models(
client: &dyn HttpClient,
api_url: &str,
api_key: Option<&str>,
) -> Result<Vec<AvailableModel>, LanguageModelCompletionError> {
let uri = format!("{api_url}/models?include_mappings=true");
let mut request_builder = HttpRequest::builder()
.method(Method::GET)
.uri(uri)
.header("Accept", "application/json");
if let Some(api_key) = api_key {
request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
}
let request = request_builder
.body(AsyncBody::default())
.map_err(|error| LanguageModelCompletionError::BuildRequestBody {
provider: PROVIDER_NAME,
error,
})?;
let mut response =
client
.send(request)
.await
.map_err(|error| LanguageModelCompletionError::HttpSend {
provider: PROVIDER_NAME,
error,
})?;
let mut body = String::new();
response
.body_mut()
.read_to_string(&mut body)
.await
.map_err(|error| LanguageModelCompletionError::ApiReadResponseError {
provider: PROVIDER_NAME,
error,
})?;
if !response.status().is_success() {
return Err(LanguageModelCompletionError::from_http_status(
PROVIDER_NAME,
response.status(),
extract_error_message(&body),
None,
));
}
let response: ModelsResponse = serde_json::from_str(&body).map_err(|error| {
LanguageModelCompletionError::DeserializeResponse {
provider: PROVIDER_NAME,
error,
}
})?;
let mut models = Vec::new();
for model in response.data {
if let Some(model_type) = model.r#type.as_deref()
&& model_type != "language"
{
continue;
}
let supports_tools = model
.supported_parameters
.iter()
.any(|parameter| parameter == "tools")
|| has_tag(&model.tags, "tool-use")
|| has_tag(&model.tags, "tools");
let supports_images = model.architecture.is_some_and(|architecture| {
architecture
.input_modalities
.iter()
.any(|modality| modality == "image")
}) || has_tag(&model.tags, "vision")
|| has_tag(&model.tags, "image-input");
let parallel_tool_calls = model
.supported_parameters
.iter()
.any(|parameter| parameter == "parallel_tool_calls");
let prompt_cache_key = model
.supported_parameters
.iter()
.any(|parameter| parameter == "prompt_cache_key" || parameter == "cache_control");
models.push(AvailableModel {
name: model.id.clone(),
display_name: model.name.or(Some(model.id)),
max_tokens: model.context_window.or(model.max_tokens).unwrap_or(128_000),
max_output_tokens: model.max_tokens,
max_completion_tokens: None,
capabilities: ModelCapabilities {
tools: supports_tools,
images: supports_images,
parallel_tool_calls,
prompt_cache_key,
chat_completions: true,
interleaved_reasoning: false,
},
});
}
Ok(models)
}
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, "vck_000000000000000000000000000"));
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))) {
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;
}
self.api_key_editor
.update(cx, |editor, cx| editor.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, |editor, cx| editor.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 env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
} else {
let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx);
if api_url == API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
if self.load_credentials_task.is_some() {
div().child(Label::new("Loading credentials...")).into_any()
} else if self.should_render_editor(cx) {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new(
"To use Zed's agent with Vercel AI Gateway, you need to add an API key. Follow these steps:",
))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Create an API key in"))
.child(ButtonLink::new(
"Vercel AI Gateway's console",
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway",
)),
)
.child(ListBulletItem::new(
"Paste your API key below and hit enter to start using the assistant",
)),
)
.child(self.api_key_editor.clone())
.child(
Label::new(format!(
"You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.",
))
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.when(env_var_set, |this| {
this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
})
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.into_any_element()
}
}
}

View File

@@ -0,0 +1,490 @@
use anyhow::Result;
use collections::BTreeMap;
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window};
use http_client::HttpClient;
use language_model::{
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
env_var,
};
use open_ai::ResponseStreamEvent;
pub use settings::XaiAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use x_ai::XAI_API_URL;
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI");
const API_KEY_ENV_VAR_NAME: &str = "XAI_API_KEY";
static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
#[derive(Default, Clone, Debug, PartialEq)]
pub struct XAiSettings {
pub api_url: String,
pub available_models: Vec<AvailableModel>,
}
pub struct XAiLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
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 = XAiLanguageModelProvider::api_url(cx);
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 = XAiLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
)
}
}
impl XAiLanguageModelProvider {
pub fn new(
http_client: Arc<dyn HttpClient>,
credentials_provider: Arc<dyn CredentialsProvider>,
cx: &mut App,
) -> Self {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let credentials_provider = this.credentials_provider.clone();
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
|this| &mut this.api_key_state,
credentials_provider,
cx,
);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
credentials_provider,
}
});
Self { http_client, state }
}
fn create_language_model(&self, model: x_ai::Model) -> Arc<dyn LanguageModel> {
Arc::new(XAiLanguageModel {
id: LanguageModelId::from(model.id().to_string()),
model,
state: self.state.clone(),
http_client: self.http_client.clone(),
request_limiter: RateLimiter::new(4),
})
}
fn settings(cx: &App) -> &XAiSettings {
&crate::AllLanguageModelSettings::get_global(cx).x_ai
}
fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
XAI_API_URL.into()
} else {
SharedString::new(api_url.as_str())
}
}
}
impl LanguageModelProviderState for XAiLanguageModelProvider {
type ObservableEntity = State;
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
Some(self.state.clone())
}
}
impl LanguageModelProvider for XAiLanguageModelProvider {
fn id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn icon(&self) -> IconOrSvg {
IconOrSvg::Icon(IconName::AiXAi)
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(x_ai::Model::default()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(x_ai::Model::default_fast()))
}
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
let mut models = BTreeMap::default();
for model in x_ai::Model::iter() {
if !matches!(model, x_ai::Model::Custom { .. }) {
models.insert(model.id().to_string(), model);
}
}
for model in &Self::settings(cx).available_models {
models.insert(
model.name.clone(),
x_ai::Model::Custom {
name: model.name.clone(),
display_name: model.display_name.clone(),
max_tokens: model.max_tokens,
max_output_tokens: model.max_output_tokens,
max_completion_tokens: model.max_completion_tokens,
supports_images: model.supports_images,
supports_tools: model.supports_tools,
parallel_tool_calls: model.parallel_tool_calls,
},
);
}
models
.into_values()
.map(|model| self.create_language_model(model))
.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| ConfigurationView::new(self.state.clone(), 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 XAiLanguageModel {
id: LanguageModelId,
model: x_ai::Model,
state: Entity<State>,
http_client: Arc<dyn HttpClient>,
request_limiter: RateLimiter,
}
impl XAiLanguageModel {
fn stream_completion(
&self,
request: open_ai::Request,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>,
LanguageModelCompletionError,
>,
> {
let http_client = self.http_client.clone();
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
let api_url = XAiLanguageModelProvider::api_url(cx);
(state.api_key_state.key(&api_url), api_url)
});
let future = self.request_limiter.stream(async move {
let provider = PROVIDER_NAME;
let Some(api_key) = api_key else {
return Err(LanguageModelCompletionError::NoApiKey { provider });
};
let request = open_ai::stream_completion(
http_client.as_ref(),
provider.0.as_str(),
&api_url,
&api_key,
request,
);
let response = request.await?;
Ok(response)
});
async move { Ok(future.await?.boxed()) }.boxed()
}
}
impl LanguageModel for XAiLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
LanguageModelName::from(self.model.display_name().to_string())
}
fn provider_id(&self) -> LanguageModelProviderId {
PROVIDER_ID
}
fn provider_name(&self) -> LanguageModelProviderName {
PROVIDER_NAME
}
fn supports_tools(&self) -> bool {
self.model.supports_tool()
}
fn supports_images(&self) -> bool {
self.model.supports_images()
}
fn supports_streaming_tools(&self) -> bool {
true
}
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
match choice {
LanguageModelToolChoice::Auto
| LanguageModelToolChoice::Any
| LanguageModelToolChoice::None => true,
}
}
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
if self.model.requires_json_schema_subset() {
LanguageModelToolSchemaFormat::JsonSchemaSubset
} else {
LanguageModelToolSchemaFormat::JsonSchema
}
}
fn telemetry_id(&self) -> String {
format!("x_ai/{}", self.model.id())
}
fn max_token_count(&self) -> u64 {
self.model.max_token_count()
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens()
}
fn supports_split_token_display(&self) -> bool {
true
}
fn stream_completion(
&self,
request: LanguageModelRequest,
cx: &AsyncApp,
) -> BoxFuture<
'static,
Result<
futures::stream::BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
let request = crate::provider::open_ai::into_open_ai(
request,
self.model.id(),
self.model.supports_parallel_tool_calls(),
self.model.supports_prompt_cache_key(),
self.max_output_tokens(),
None,
false,
);
let completions = self.stream_completion(request, cx);
async move {
let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
Ok(mapper.map_stream(completions.await?).boxed())
}
.boxed()
}
}
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,
"xai-0000000000000000000000000000000000000000000000000",
)
.label("API key")
});
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, |editor, cx| editor.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: &mut 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 env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
} else {
let api_url = XAiLanguageModelProvider::api_url(cx);
if api_url == XAI_API_URL {
"API key configured".to_string()
} else {
format!("API key configured for {}", api_url)
}
};
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 xAI, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys"))
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
),
)
.child(self.api_key_editor.clone())
.child(
Label::new(format!(
"You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
))
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Label::new("Note that xAI is a custom OpenAI-compatible provider.")
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.when(env_var_set, |this| {
this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
})
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.into_any_element()
};
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()
}
}
}

View File

@@ -0,0 +1,129 @@
use std::sync::Arc;
use collections::HashMap;
use settings::RegisterSetting;
use crate::provider::{
anthropic::AnthropicSettings, bedrock::AmazonBedrockSettings, cloud::ZedDotDevSettings,
deepseek::DeepSeekSettings, google::GoogleSettings, lmstudio::LmStudioSettings,
mistral::MistralSettings, ollama::OllamaSettings, open_ai::OpenAiSettings,
open_ai_compatible::OpenAiCompatibleSettings, open_router::OpenRouterSettings,
opencode::OpenCodeSettings, vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings,
};
#[derive(Debug, RegisterSetting)]
pub struct AllLanguageModelSettings {
pub anthropic: AnthropicSettings,
pub bedrock: AmazonBedrockSettings,
pub deepseek: DeepSeekSettings,
pub google: GoogleSettings,
pub lmstudio: LmStudioSettings,
pub mistral: MistralSettings,
pub ollama: OllamaSettings,
pub opencode: OpenCodeSettings,
pub open_router: OpenRouterSettings,
pub openai: OpenAiSettings,
pub openai_compatible: HashMap<Arc<str>, OpenAiCompatibleSettings>,
pub vercel_ai_gateway: VercelAiGatewaySettings,
pub x_ai: XAiSettings,
pub zed_dot_dev: ZedDotDevSettings,
}
impl settings::Settings for AllLanguageModelSettings {
const PRESERVED_KEYS: Option<&'static [&'static str]> = Some(&["version"]);
fn from_settings(content: &settings::SettingsContent) -> Self {
let language_models = content.language_models.clone().unwrap();
let anthropic = language_models.anthropic.unwrap();
let bedrock = language_models.bedrock.unwrap();
let deepseek = language_models.deepseek.unwrap();
let google = language_models.google.unwrap();
let lmstudio = language_models.lmstudio.unwrap();
let mistral = language_models.mistral.unwrap();
let ollama = language_models.ollama.unwrap();
let opencode = language_models.opencode.unwrap();
let open_router = language_models.open_router.unwrap();
let openai = language_models.openai.unwrap();
let openai_compatible = language_models.openai_compatible.unwrap();
let vercel_ai_gateway = language_models.vercel_ai_gateway.unwrap();
let x_ai = language_models.x_ai.unwrap();
let zed_dot_dev = language_models.zed_dot_dev.unwrap();
Self {
anthropic: AnthropicSettings {
api_url: anthropic.api_url.unwrap(),
available_models: anthropic.available_models.unwrap_or_default(),
},
bedrock: AmazonBedrockSettings {
available_models: bedrock.available_models.unwrap_or_default(),
region: bedrock.region,
endpoint: bedrock.endpoint_url, // todo(should be api_url)
profile_name: bedrock.profile,
role_arn: None, // todo(was never a setting for this...)
authentication_method: bedrock.authentication_method.map(Into::into),
allow_global: bedrock.allow_global,
guardrail_identifier: bedrock.guardrail_identifier,
guardrail_version: bedrock.guardrail_version,
},
deepseek: DeepSeekSettings {
api_url: deepseek.api_url.unwrap(),
available_models: deepseek.available_models.unwrap_or_default(),
},
google: GoogleSettings {
api_url: google.api_url.unwrap(),
available_models: google.available_models.unwrap_or_default(),
},
lmstudio: LmStudioSettings {
api_url: lmstudio.api_url.unwrap(),
available_models: lmstudio.available_models.unwrap_or_default(),
},
mistral: MistralSettings {
api_url: mistral.api_url.unwrap(),
available_models: mistral.available_models.unwrap_or_default(),
},
ollama: OllamaSettings {
api_url: ollama.api_url.unwrap(),
auto_discover: ollama.auto_discover.unwrap_or(true),
available_models: ollama.available_models.unwrap_or_default(),
context_window: ollama.context_window,
},
opencode: OpenCodeSettings {
api_url: opencode.api_url.unwrap(),
available_models: opencode.available_models.unwrap_or_default(),
show_zen_models: opencode.show_zen_models.unwrap_or(true),
show_go_models: opencode.show_go_models.unwrap_or(true),
show_free_models: opencode.show_free_models.unwrap_or(true),
},
open_router: OpenRouterSettings {
api_url: open_router.api_url.unwrap(),
available_models: open_router.available_models.unwrap_or_default(),
},
openai: OpenAiSettings {
api_url: openai.api_url.unwrap(),
available_models: openai.available_models.unwrap_or_default(),
},
openai_compatible: openai_compatible
.into_iter()
.map(|(key, value)| {
(
key,
OpenAiCompatibleSettings {
api_url: value.api_url,
available_models: value.available_models,
},
)
})
.collect(),
vercel_ai_gateway: VercelAiGatewaySettings {
api_url: vercel_ai_gateway.api_url.unwrap(),
available_models: vercel_ai_gateway.available_models.unwrap_or_default(),
},
x_ai: XAiSettings {
api_url: x_ai.api_url.unwrap(),
available_models: x_ai.available_models.unwrap_or_default(),
},
zed_dot_dev: ZedDotDevSettings {
available_models: zed_dot_dev.available_models.unwrap_or_default(),
},
}
}
}