diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 4b0f8e5992..f8097b1798 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -1,21 +1,25 @@ 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, Window}; -use http_client::HttpClient; +use http_client::{AsyncBody, HttpClient, http}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, env_var, }; -use opencode::{ApiProtocol, OPENCODE_API_URL}; +use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription}; pub use settings::OpenCodeAvailableModel as AvailableModel; -use settings::{Settings, SettingsStore}; +use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; +use ui::{ + Banner, ButtonLink, ConfiguredApiCard, List, ListBulletItem, Severity, Switch, + SwitchLabelPosition, ToggleState, prelude::*, +}; use ui_input::InputField; use util::ResultExt; @@ -26,7 +30,7 @@ use crate::provider::open_ai::{ }; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opencode"); -const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenCode Zen"); +const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenCode"); const API_KEY_ENV_VAR_NAME: &str = "OPENCODE_API_KEY"; static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); @@ -35,6 +39,9 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); pub struct OpenCodeSettings { pub api_url: String, pub available_models: Vec, + pub show_zen_models: bool, + pub show_go_models: bool, + pub show_free_models: bool, } pub struct OpenCodeLanguageModelProvider { @@ -104,10 +111,16 @@ impl OpenCodeLanguageModelProvider { Self { http_client, state } } - fn create_language_model(&self, model: opencode::Model) -> Arc { + fn create_language_model( + &self, + model: opencode::Model, + subscription: OpenCodeSubscription, + ) -> Arc { + let id_str = format!("{}/{}", subscription.id_prefix(), model.id()); Arc::new(OpenCodeLanguageModel { - id: LanguageModelId::from(model.id().to_string()), + id: LanguageModelId::from(id_str), model, + subscription, state: self.state.clone(), http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), @@ -118,6 +131,15 @@ impl OpenCodeLanguageModelProvider { &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() { @@ -149,24 +171,68 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { IconOrSvg::Icon(IconName::AiOpenCode) } - fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(opencode::Model::default())) + fn default_model(&self, cx: &App) -> Option> { + 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> { - Some(self.create_language_model(opencode::Model::default_fast())) + fn default_fast_model(&self, cx: &App) -> Option> { + 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> { - let mut models = BTreeMap::default(); + let mut models: BTreeMap = + BTreeMap::default(); + let settings = Self::settings(cx); for model in opencode::Model::iter() { - if !matches!(model, opencode::Model::Custom { .. }) { - models.insert(model.id().to_string(), model); + 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 &Self::settings(cx).available_models { + for model in &settings.available_models { let protocol = match model.protocol.as_str() { "anthropic" => ApiProtocol::Anthropic, "openai_responses" => ApiProtocol::OpenAiResponses, @@ -174,21 +240,29 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { "google" => ApiProtocol::Google, _ => ApiProtocol::OpenAiChat, // default fallback }; - models.insert( - model.name.clone(), - 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, - }, - ); + 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, + custom_model_api_url: model.custom_model_api_url.clone(), + }; + let key = format!("{}/{}", subscription.id_prefix(), model.name); + models.insert(key, (custom_model, subscription)); } models .into_values() - .map(|model| self.create_language_model(model)) + .map(|(model, subscription)| self.create_language_model(model, subscription)) .collect() } @@ -219,16 +293,56 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { pub struct OpenCodeLanguageModel { id: LanguageModelId, model: opencode::Model, + subscription: OpenCodeSubscription, state: Entity, http_client: Arc, request_limiter: RateLimiter, } +struct InjectHeaderClient { + inner: Arc, + 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, + ) -> futures::future::BoxFuture<'static, anyhow::Result>> { + req.headers_mut() + .insert(self.name.clone(), self.value.clone()); + self.inner.send(req) + } +} + impl OpenCodeLanguageModel { - /// Returns the base API URL (e.g., "https://opencode.ai/zen"). fn base_api_url(&self, cx: &AsyncApp) -> SharedString { - self.state - .read_with(cx, |_, cx| OpenCodeLanguageModelProvider::api_url(cx)) + // 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> { @@ -241,6 +355,7 @@ impl OpenCodeLanguageModel { fn stream_anthropic( &self, request: anthropic::Request, + http_client: Arc, cx: &AsyncApp, ) -> BoxFuture< 'static, @@ -252,7 +367,6 @@ impl OpenCodeLanguageModel { LanguageModelCompletionError, >, > { - let http_client = self.http_client.clone(); // Anthropic crate appends /v1/messages to api_url let api_url = self.base_api_url(cx); let api_key = self.api_key(cx); @@ -280,12 +394,12 @@ impl OpenCodeLanguageModel { fn stream_openai_chat( &self, request: open_ai::Request, + http_client: Arc, cx: &AsyncApp, ) -> BoxFuture< 'static, Result>>, > { - let http_client = self.http_client.clone(); // 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(); @@ -315,12 +429,12 @@ impl OpenCodeLanguageModel { fn stream_openai_response( &self, request: open_ai::responses::Request, + http_client: Arc, cx: &AsyncApp, ) -> BoxFuture< 'static, Result>>, > { - let http_client = self.http_client.clone(); // 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(); @@ -347,15 +461,15 @@ impl OpenCodeLanguageModel { async move { Ok(future.await?.boxed()) }.boxed() } - fn stream_google_zen( + fn stream_google( &self, request: google_ai::GenerateContentRequest, + http_client: Arc, cx: &AsyncApp, ) -> BoxFuture< 'static, Result>>, > { - let http_client = self.http_client.clone(); let api_url = self.base_api_url(cx); let api_key = self.api_key(cx); @@ -365,7 +479,7 @@ impl OpenCodeLanguageModel { provider: PROVIDER_NAME, }); }; - let request = opencode::stream_generate_content_zen( + let request = opencode::stream_generate_content( http_client.as_ref(), &api_url, &api_key, @@ -385,7 +499,11 @@ impl LanguageModel for OpenCodeLanguageModel { } fn name(&self) -> LanguageModelName { - LanguageModelName::from(self.model.display_name().to_string()) + LanguageModelName::from(format!( + "{}: {}", + self.subscription.display_name(), + self.model.display_name() + )) } fn provider_id(&self) -> LanguageModelProviderId { @@ -409,13 +527,17 @@ impl LanguageModel for OpenCodeLanguageModel { LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => true, LanguageModelToolChoice::None => { // Google models don't support None tool choice - self.model.protocol() != ApiProtocol::Google + self.model.protocol(self.subscription) != ApiProtocol::Google } } } fn telemetry_id(&self) -> String { - format!("opencode/{}", self.model.id()) + format!( + "opencode/{}/{}", + self.subscription.id_prefix(), + self.model.id() + ) } fn max_token_count(&self) -> u64 { @@ -440,7 +562,19 @@ impl LanguageModel for OpenCodeLanguageModel { LanguageModelCompletionError, >, > { - match self.model.protocol() { + 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 anthropic_request = into_anthropic( request, @@ -449,7 +583,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.max_output_tokens().unwrap_or(8192), anthropic::AnthropicModelMode::Default, ); - let stream = self.stream_anthropic(anthropic_request, cx); + let stream = self.stream_anthropic(anthropic_request, http_client, cx); async move { let mapper = AnthropicEventMapper::new(); Ok(mapper.map_stream(stream.await?).boxed()) @@ -466,7 +600,7 @@ impl LanguageModel for OpenCodeLanguageModel { None, false, ); - let stream = self.stream_openai_chat(openai_request, cx); + let stream = self.stream_openai_chat(openai_request, http_client, cx); async move { let mapper = OpenAiEventMapper::new(); Ok(mapper.map_stream(stream.await?).boxed()) @@ -482,7 +616,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.max_output_tokens(), None, ); - let stream = self.stream_openai_response(response_request, cx); + let stream = self.stream_openai_response(response_request, http_client, cx); async move { let mapper = OpenAiResponseEventMapper::new(); Ok(mapper.map_stream(stream.await?).boxed()) @@ -495,7 +629,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.id().to_string(), google_ai::GoogleModelMode::Default, ); - let stream = self.stream_google_zen(google_request, cx); + let stream = self.stream_google(google_request, http_client, cx); async move { let mapper = GoogleEventMapper::new(); Ok(mapper.map_stream(stream.await?.boxed()).boxed()) @@ -575,6 +709,30 @@ impl ConfigurationView { .detach_and_log_err(cx); } + fn set_subscription_enabled( + &mut self, + subscription: OpenCodeSubscription, + is_enabled: bool, + _window: &mut Window, + cx: &mut Context, + ) { + let 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) -> bool { !self.state.read(cx).is_authenticated() } @@ -598,7 +756,7 @@ impl Render for ConfigurationView { v_flex() .on_action(cx.listener(Self::save_api_key)) .child(Label::new( - "To use OpenCode Zen models in Zed, you need an API key:", + "To use OpenCode models in Zed, you need an API key:", )) .child( List::new() @@ -606,12 +764,12 @@ impl Render for ConfigurationView { ListBulletItem::new("") .child(Label::new("Sign in and get your key at")) .child(ButtonLink::new( - "OpenCode Zen Console", - "https://opencode.ai/zen", + "OpenCode Console", + "https://opencode.ai/auth", )), ) .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using OpenCode Zen", + "Paste your API key below and hit enter to start using OpenCode", )), ) .child(self.api_key_editor.clone()) @@ -638,7 +796,69 @@ impl Render for ConfigurationView { 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() + 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() } } } diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index f60d4c6cb4..a8d1b384d3 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -91,6 +91,9 @@ impl settings::Settings for AllLanguageModelSettings { 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(), diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index 4a40d472bd..9278d81677 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -17,6 +17,40 @@ pub enum ApiProtocol { Google, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum OpenCodeSubscription { + Zen, + Go, + Free, +} + +impl OpenCodeSubscription { + pub fn display_name(&self) -> &'static str { + match self { + Self::Zen => "Zen", + Self::Go => "Go", + Self::Free => "Free", + } + } + + pub fn id_prefix(&self) -> &'static str { + match self { + Self::Zen => "zen", + Self::Go => "go", + Self::Free => "free", + } + } + + pub fn api_path_suffix(&self) -> &'static str { + match self { + Self::Zen | Self::Free => "", + Self::Go => "/go", + } + } +} + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { @@ -86,20 +120,26 @@ pub enum Model { MiniMaxM2_5Free, #[serde(rename = "glm-5")] Glm5, + #[serde(rename = "glm-5.1")] + Glm5_1, #[serde(rename = "kimi-k2.5")] KimiK2_5, - #[serde(rename = "mimo-v2-pro-free")] - MimoV2ProFree, - #[serde(rename = "mimo-v2-omni-free")] - MimoV2OmniFree, - #[serde(rename = "mimo-v2-flash-free")] - MimoV2FlashFree, - #[serde(rename = "trinity-large-preview-free")] - TrinityLargePreviewFree, + #[serde(rename = "kimi-k2.6")] + KimiK2_6, + #[serde(rename = "minimax-m2.7")] + MiniMaxM2_7, + #[serde(rename = "mimo-v2-pro")] + MimoV2Pro, + #[serde(rename = "mimo-v2-omni")] + MimoV2Omni, #[serde(rename = "big-pickle")] BigPickle, #[serde(rename = "nemotron-3-super-free")] Nemotron3SuperFree, + #[serde(rename = "qwen3.5-plus")] + Qwen3_5Plus, + #[serde(rename = "qwen3.6-plus")] + Qwen3_6Plus, // -- Custom model -- #[serde(rename = "custom")] @@ -109,6 +149,7 @@ pub enum Model { max_tokens: u64, max_output_tokens: Option, protocol: ApiProtocol, + custom_model_api_url: Option, }, } @@ -117,6 +158,49 @@ impl Model { Self::ClaudeHaiku4_5 } + pub fn default_go() -> Self { + Self::KimiK2_5 + } + + pub fn default_go_fast() -> Self { + Self::MiniMaxM2_5 + } + + pub fn default_free() -> Self { + Self::BigPickle + } + + pub fn default_free_fast() -> Self { + Self::MiniMaxM2_5Free + } + + pub fn available_subscriptions(&self) -> &'static [OpenCodeSubscription] { + match self { + // Models available in both Zen and Go + Self::Glm5 + | Self::Glm5_1 + | Self::KimiK2_6 + | Self::KimiK2_5 + | Self::MiniMaxM2_5 + | Self::Qwen3_5Plus + | Self::Qwen3_6Plus => &[OpenCodeSubscription::Zen, OpenCodeSubscription::Go], + + // Go-only models + Self::MiniMaxM2_7 | Self::MimoV2Pro | Self::MimoV2Omni => &[OpenCodeSubscription::Go], + + // Free models + Self::MiniMaxM2_5Free | Self::Nemotron3SuperFree | Self::BigPickle => { + &[OpenCodeSubscription::Free] + } + + // Custom models get their subscription from settings, not from here + Self::Custom { .. } => &[], + + // All other built-in models are Zen-only + _ => &[OpenCodeSubscription::Zen], + } + } + pub fn id(&self) -> &str { match self { Self::ClaudeOpus4_7 => "claude-opus-4-7", @@ -151,11 +235,14 @@ impl Model { Self::MiniMaxM2_5 => "minimax-m2.5", Self::MiniMaxM2_5Free => "minimax-m2.5-free", Self::Glm5 => "glm-5", + Self::Glm5_1 => "glm-5.1", Self::KimiK2_5 => "kimi-k2.5", - Self::MimoV2ProFree => "mimo-v2-pro-free", - Self::MimoV2OmniFree => "mimo-v2-omni-free", - Self::MimoV2FlashFree => "mimo-v2-flash-free", - Self::TrinityLargePreviewFree => "trinity-large-preview-free", + Self::KimiK2_6 => "kimi-k2.6", + Self::MiniMaxM2_7 => "minimax-m2.7", + Self::MimoV2Pro => "mimo-v2-pro", + Self::MimoV2Omni => "mimo-v2-omni", + Self::Qwen3_5Plus => "qwen3.5-plus", + Self::Qwen3_6Plus => "qwen3.6-plus", Self::BigPickle => "big-pickle", Self::Nemotron3SuperFree => "nemotron-3-super-free", @@ -197,11 +284,14 @@ impl Model { Self::MiniMaxM2_5 => "MiniMax M2.5", Self::MiniMaxM2_5Free => "MiniMax M2.5 Free", Self::Glm5 => "GLM 5", + Self::Glm5_1 => "GLM 5.1", Self::KimiK2_5 => "Kimi K2.5", - Self::MimoV2ProFree => "MiMo V2 Pro Free", - Self::MimoV2OmniFree => "MiMo V2 Omni Free", - Self::MimoV2FlashFree => "MiMo V2 Flash Free", - Self::TrinityLargePreviewFree => "Trinity Large Preview Free", + Self::KimiK2_6 => "Kimi K2.6", + Self::MiniMaxM2_7 => "MiniMax M2.7", + Self::MimoV2Pro => "MiMo V2 Pro", + Self::MimoV2Omni => "MiMo V2 Omni", + Self::Qwen3_5Plus => "Qwen3.5 Plus", + Self::Qwen3_6Plus => "Qwen3.6 Plus", Self::BigPickle => "Big Pickle", Self::Nemotron3SuperFree => "Nemotron 3 Super Free", @@ -211,8 +301,18 @@ impl Model { } } - pub fn protocol(&self) -> ApiProtocol { + pub fn protocol(&self, subscription: OpenCodeSubscription) -> ApiProtocol { match self { + // Models offered by OpenCode have the same configuration across subscriptions + // with one outlier: non-free MiniMax models + Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => { + if subscription == OpenCodeSubscription::Zen { + ApiProtocol::OpenAiChat + } else { + ApiProtocol::Anthropic + } + } + Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 @@ -241,14 +341,15 @@ impl Model { Self::Gemini3_1Pro | Self::Gemini3Flash => ApiProtocol::Google, - Self::MiniMaxM2_5 - | Self::MiniMaxM2_5Free + Self::MiniMaxM2_5Free | Self::Glm5 + | Self::Glm5_1 | Self::KimiK2_5 - | Self::MimoV2ProFree - | Self::MimoV2OmniFree - | Self::MimoV2FlashFree - | Self::TrinityLargePreviewFree + | Self::KimiK2_6 + | Self::MimoV2Pro + | Self::MimoV2Omni + | Self::Qwen3_5Plus + | Self::Qwen3_6Plus | Self::BigPickle | Self::Nemotron3SuperFree => ApiProtocol::OpenAiChat, @@ -259,10 +360,12 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { // Anthropic models - Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000, - Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 => 200_000, + Self::ClaudeOpus4_7 => 1_000_000, + Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000, + Self::ClaudeSonnet4_5 => 1_000_000, + Self::ClaudeOpus4_5 | Self::ClaudeHaiku4_5 => 200_000, Self::ClaudeOpus4_1 => 200_000, - Self::ClaudeHaiku4_5 => 200_000, + Self::ClaudeSonnet4 => 1_000_000, Self::Claude3_5Haiku => 200_000, // OpenAI models @@ -281,14 +384,15 @@ impl Model { Self::Gemini3Flash => 1_048_576, // OpenAI-compatible models - Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 196_608, - Self::Glm5 => 200_000, - Self::KimiK2_5 => 262_144, - Self::MimoV2ProFree => 1_048_576, - Self::MimoV2OmniFree | Self::MimoV2FlashFree => 262_144, - Self::TrinityLargePreviewFree => 131_072, + Self::MiniMaxM2_7 => 204_800, + Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 204_800, + Self::Glm5 | Self::Glm5_1 => 204_800, + Self::KimiK2_6 | Self::KimiK2_5 => 262_144, + Self::MimoV2Pro => 1_048_576, + Self::MimoV2Omni => 262_144, + Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144, Self::BigPickle => 200_000, - Self::Nemotron3SuperFree => 262_144, + Self::Nemotron3SuperFree => 204_800, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -298,12 +402,12 @@ impl Model { match self { // Anthropic models Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), - Self::ClaudeSonnet4_6 => Some(64_000), Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeHaiku4_5 => Some(64_000), + | Self::ClaudeHaiku4_5 + | Self::ClaudeSonnet4 => Some(64_000), + Self::ClaudeOpus4_1 => Some(32_000), Self::Claude3_5Haiku => Some(8_192), // OpenAI models @@ -327,12 +431,14 @@ impl Model { Self::Gemini3_1Pro | Self::Gemini3Flash => Some(65_536), // OpenAI-compatible models - Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(65_536), - Self::Glm5 | Self::BigPickle => Some(128_000), - Self::KimiK2_5 => Some(65_536), - Self::MimoV2ProFree => Some(131_072), - Self::MimoV2OmniFree | Self::MimoV2FlashFree => Some(65_536), - Self::TrinityLargePreviewFree | Self::Nemotron3SuperFree => Some(16_384), + Self::MiniMaxM2_7 => Some(131_072), + Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(131_072), + Self::Glm5 | Self::Glm5_1 => Some(131_072), + Self::BigPickle => Some(128_000), + Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), + Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536), + Self::Nemotron3SuperFree => Some(128_000), + Self::MimoV2Pro | Self::MimoV2Omni => Some(64_000), Self::Custom { max_output_tokens, .. @@ -377,15 +483,20 @@ impl Model { // Google models support images Self::Gemini3_1Pro | Self::Gemini3Flash => true, - // OpenAI-compatible models — conservative default + // OpenAI-compatible models with image support + Self::KimiK2_6 + | Self::KimiK2_5 + | Self::MimoV2Omni + | Self::Qwen3_5Plus + | Self::Qwen3_6Plus => true, + + // OpenAI-compatible models without image support Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free | Self::Glm5 - | Self::KimiK2_5 - | Self::MimoV2ProFree - | Self::MimoV2OmniFree - | Self::MimoV2FlashFree - | Self::TrinityLargePreviewFree + | Self::Glm5_1 + | Self::MiniMaxM2_7 + | Self::MimoV2Pro | Self::BigPickle | Self::Nemotron3SuperFree => false, @@ -400,12 +511,12 @@ impl Model { } } -/// Stream generate content for Google models via OpenCode Zen. +/// Stream generate content for Google models via OpenCode. /// /// Unlike `google_ai::stream_generate_content()`, this uses: /// - `/v1/models/{model}` path (not `/v1beta/models/{model}`) /// - `Authorization: Bearer` header (not `key=` query param) -pub async fn stream_generate_content_zen( +pub async fn stream_generate_content( client: &dyn HttpClient, api_url: &str, api_key: &str, @@ -451,7 +562,7 @@ pub async fn stream_generate_content_zen( let mut text = String::new(); response.body_mut().read_to_string(&mut text).await?; Err(anyhow!( - "error during streamGenerateContent via OpenCode Zen, status code: {:?}, body: {}", + "error during streamGenerateContent via OpenCode, status code: {:?}, body: {}", response.status(), text )) diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index c14f854a72..1a16c5264a 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -150,6 +150,20 @@ impl Default for KeepAlive { pub struct OpenCodeSettingsContent { pub api_url: Option, pub available_models: Option>, + /// Whether to show OpenCode Zen models. Defaults to true. + pub show_zen_models: Option, + /// Whether to show OpenCode Go models. Defaults to true. + pub show_go_models: Option, + /// Whether to show OpenCode Free models. Defaults to true. + pub show_free_models: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)] +#[serde(rename_all = "snake_case")] +pub enum OpenCodeModelSubscription { + Zen, + Go, + Free, } #[with_fallible_options] @@ -161,6 +175,10 @@ pub struct OpenCodeAvailableModel { pub max_output_tokens: Option, /// The API protocol to use for this model: "anthropic", "openai_responses", "openai_chat", or "google". pub protocol: String, + /// The subscription for this model: "zen", "go", or "free". Defaults to Zen. + pub subscription: Option, + /// Custom Model API URL to use for this model. + pub custom_model_api_url: Option, } #[with_fallible_options] diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 92c490a05d..ec217ff081 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -31,6 +31,7 @@ Zed supports these providers with your own API keys: - [Ollama](#ollama) - [OpenAI](#openai) - [OpenAI API Compatible](#openai-api-compatible) +- [OpenCode](#opencode) - [OpenRouter](#openrouter) - [Vercel AI Gateway](#vercel-ai-gateway) - [Vercel](#vercel-v0) @@ -614,6 +615,74 @@ If a provider exposes models that only work with the Responses API, set `chat_co Note that LLM API keys aren't stored in your settings file. So, ensure you have it set in your environment variables (`_API_KEY=`) so your settings can pick it up. In the example above, it would be `TOGETHER_AI_API_KEY=`. +### OpenCode {#opencode} + +OpenCode offers multiple ways to access AI models: + +- [OpenCode Zen](https://opencode.ai/zen/): a pay-as-you-go subscription with access to a large number of tested and verified models +- [OpenCode Zen Free](https://opencode.ai/docs/zen/#pricing): free access to a limited set of models, with data and feedback collected to improve the models +- [OpenCode Go](https://opencode.ai/go): a low-cost monthly subscription with access to a validated set of open coding models + +1. Visit [OpenCode Console](https://opencode.ai/auth) and create an account +2. Free models are available without payment. To use Zen or Go models, make sure you have enough credits or an active subscription +3. Generate an API key from the "API Keys" section in the OpenCode Console +4. Open the settings view (`agent: open settings`) and go to the OpenCode section +5. Enter your OpenCode API key + +The OpenCode API key will be saved in your keychain. + +Zed will also use the `OPENCODE_API_KEY` environment variable if it's defined. + +By default, models from all subscription types are shown. Optionally, you can hide subscriptions that are not relevant to you by clicking the toggles or by adding the following to your settings: + +```json [settings] +{ + "language_models": { + "opencode": { + "show_zen_models": true, + "show_go_models": false, + "show_free_models": false + } + } +} +``` + +#### Custom Models {#opencode-custom-models} + +The Zed agent comes pre-configured with OpenCode models. If you wish to use newer models or models with custom endpoints, you can do so by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)): + +```json [settings] +{ + "language_models": { + "opencode": { + "available_models": [ + { + "name": "my-custom-model", + "display_name": "My Custom Model", + "max_tokens": 123456, + "max_output_tokens": 98765, + "protocol": "openai_chat", + "subscription": "go", + "custom_model_api_url": "https://example.com/zen" + } + ] + } + } +} +``` + +The available configuration options for custom models are: + +- `name` (required): model id used by OpenCode, for example `glm-9000` +- `display_name` (optional): human-readable model name shown in the UI, for example `Custom GLM 9000` +- `max_tokens` (required): maximum model context window size, for example `1000000` +- `max_output_tokens` (optional): maximum tokens the model can generate, for example `64000` +- `protocol` (required): model API protocol, one of `"anthropic"`, `"openai_responses"`, `"openai_chat"`, or `"google"` +- `subscription` (optional): `"zen"`, `"go"`, or `"free"` (defaults to `"zen"`) +- `custom_model_api_url` (optional): custom API base URL to use instead of the default OpenCode API + +Custom models will be listed in the model dropdown in the Agent Panel. + ### OpenRouter {#openrouter} OpenRouter provides access to multiple AI models through a single API. It supports tool use for compatible models.