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:
75
crates/language_models/Cargo.toml
Normal file
75
crates/language_models/Cargo.toml
Normal 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"] }
|
||||
1
crates/language_models/LICENSE-GPL
Symbolic link
1
crates/language_models/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
67
crates/language_models/src/extension.rs
Normal file
67
crates/language_models/src/extension.rs
Normal 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));
|
||||
}
|
||||
333
crates/language_models/src/language_models.rs
Normal file
333
crates/language_models/src/language_models.rs
Normal 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);
|
||||
}
|
||||
16
crates/language_models/src/provider.rs
Normal file
16
crates/language_models/src/provider.rs
Normal 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;
|
||||
655
crates/language_models/src/provider/anthropic.rs
Normal file
655
crates/language_models/src/provider/anthropic.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
136
crates/language_models/src/provider/anthropic/telemetry.rs
Normal file
136
crates/language_models/src/provider/anthropic/telemetry.rs
Normal 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()
|
||||
))
|
||||
}
|
||||
}
|
||||
1689
crates/language_models/src/provider/bedrock.rs
Normal file
1689
crates/language_models/src/provider/bedrock.rs
Normal file
File diff suppressed because it is too large
Load Diff
778
crates/language_models/src/provider/cloud.rs
Normal file
778
crates/language_models/src/provider/cloud.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
1664
crates/language_models/src/provider/copilot_chat.rs
Normal file
1664
crates/language_models/src/provider/copilot_chat.rs
Normal file
File diff suppressed because it is too large
Load Diff
755
crates/language_models/src/provider/deepseek.rs
Normal file
755
crates/language_models/src/provider/deepseek.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
495
crates/language_models/src/provider/google.rs
Normal file
495
crates/language_models/src/provider/google.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
1012
crates/language_models/src/provider/lmstudio.rs
Normal file
1012
crates/language_models/src/provider/lmstudio.rs
Normal file
File diff suppressed because it is too large
Load Diff
1049
crates/language_models/src/provider/mistral.rs
Normal file
1049
crates/language_models/src/provider/mistral.rs
Normal file
File diff suppressed because it is too large
Load Diff
1162
crates/language_models/src/provider/ollama.rs
Normal file
1162
crates/language_models/src/provider/ollama.rs
Normal file
File diff suppressed because it is too large
Load Diff
706
crates/language_models/src/provider/open_ai.rs
Normal file
706
crates/language_models/src/provider/open_ai.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
569
crates/language_models/src/provider/open_ai_compatible.rs
Normal file
569
crates/language_models/src/provider/open_ai_compatible.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
1131
crates/language_models/src/provider/open_router.rs
Normal file
1131
crates/language_models/src/provider/open_router.rs
Normal file
File diff suppressed because it is too large
Load Diff
943
crates/language_models/src/provider/opencode.rs
Normal file
943
crates/language_models/src/provider/opencode.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
711
crates/language_models/src/provider/vercel_ai_gateway.rs
Normal file
711
crates/language_models/src/provider/vercel_ai_gateway.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
490
crates/language_models/src/provider/x_ai.rs
Normal file
490
crates/language_models/src/provider/x_ai.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
129
crates/language_models/src/settings.rs
Normal file
129
crates/language_models/src/settings.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user