agent: Add Opencode Zen provider (#49589)

grim , Ben Brandt , and Bennet Bo Fenner created

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Per Opencode's website:
> Zen gives you access to a curated set of AI models that OpenCode has
tested and benchmarked specifically for coding agents. No need to worry
about inconsistent performance and quality, use validated models that
work.
> - [x] Testing select models and consulting their teams
> - [x] Working with providers to ensure they're delivered properly
> - [x] Benchmarking all model-provider combinations we recommend

There are so many models available, but only a few work well with coding
agents. Most providers configure them differently with varying results.

The models under the Zen umbrella typically have a more reliable
token(s) per second speed with minimal outages. The opencode ecosystem
has improved my workflow if not many others' !

Release Notes:
- Added [Opencode Zen](https://opencode.ai/zen) to list of providers

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

Cargo.lock                                      |  15 
Cargo.toml                                      |   2 
assets/icons/ai_open_code.svg                   |   3 
assets/settings/default.json                    |   3 
crates/icons/src/icons.rs                       |   1 
crates/language_models/Cargo.toml               |   1 
crates/language_models/src/language_models.rs   |   5 
crates/language_models/src/provider.rs          |   1 
crates/language_models/src/provider/opencode.rs | 646 +++++++++++++++++++
crates/language_models/src/settings.rs          |   9 
crates/opencode/Cargo.toml                      |  27 
crates/opencode/LICENSE-GPL                     |   1 
crates/opencode/src/opencode.rs                 | 453 +++++++++++++
crates/settings_content/src/language_model.rs   |  19 
14 files changed, 1,185 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -9514,6 +9514,7 @@ dependencies = [
  "ollama",
  "open_ai",
  "open_router",
+ "opencode",
  "partial-json-fixer",
  "pretty_assertions",
  "release_channel",
@@ -11665,6 +11666,20 @@ dependencies = [
  "thiserror 2.0.17",
 ]
 
+[[package]]
+name = "opencode"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures 0.3.31",
+ "google_ai",
+ "http_client",
+ "schemars",
+ "serde",
+ "serde_json",
+ "strum 0.27.2",
+]
+
 [[package]]
 name = "opener"
 version = "0.7.2"

Cargo.toml 🔗

@@ -134,6 +134,7 @@ members = [
     "crates/notifications",
     "crates/ollama",
     "crates/onboarding",
+    "crates/opencode",
     "crates/open_ai",
     "crates/open_path_prompt",
     "crates/open_router",
@@ -381,6 +382,7 @@ node_runtime = { path = "crates/node_runtime" }
 notifications = { path = "crates/notifications" }
 ollama = { path = "crates/ollama" }
 onboarding = { path = "crates/onboarding" }
+opencode = { path = "crates/opencode" }
 open_ai = { path = "crates/open_ai" }
 open_path_prompt = { path = "crates/open_path_prompt" }
 open_router = { path = "crates/open_router", features = ["schemars"] }

assets/icons/ai_open_code.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.2 3.2H4.8V12.8H11.2V3.2ZM14.4 16H1.6V0H14.4V16Z" fill="black"/>
+</svg>

assets/settings/default.json 🔗

@@ -2245,6 +2245,9 @@
       "api_url": "https://api.openai.com/v1",
     },
     "openai_compatible": {},
+    "opencode": {
+      "api_url": "https://opencode.ai/zen",
+    },
     "open_router": {
       "api_url": "https://openrouter.ai/api/v1",
     },

crates/icons/src/icons.rs 🔗

@@ -22,6 +22,7 @@ pub enum IconName {
     AiOllama,
     AiOpenAi,
     AiOpenAiCompat,
+    AiOpenCode,
     AiOpenRouter,
     AiVercel,
     AiVZero,

crates/language_models/Cargo.toml 🔗

@@ -47,6 +47,7 @@ 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"] }
 partial-json-fixer.workspace = true
 release_channel.workspace = true

crates/language_models/src/language_models.rs 🔗

@@ -24,6 +24,7 @@ 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::VercelLanguageModelProvider;
 use crate::provider::vercel_ai_gateway::VercelAiGatewayLanguageModelProvider;
 use crate::provider::x_ai::XAiLanguageModelProvider;
@@ -220,5 +221,9 @@ fn register_language_model_providers(
         Arc::new(XAiLanguageModelProvider::new(client.http_client(), cx)),
         cx,
     );
+    registry.register_provider(
+        Arc::new(OpenCodeLanguageModelProvider::new(client.http_client(), cx)),
+        cx,
+    );
     registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx);
 }

crates/language_models/src/provider.rs 🔗

@@ -10,6 +10,7 @@ pub mod ollama;
 pub mod open_ai;
 pub mod open_ai_compatible;
 pub mod open_router;
+pub mod opencode;
 mod util;
 pub mod vercel;
 pub mod vercel_ai_gateway;

crates/language_models/src/provider/opencode.rs 🔗

@@ -0,0 +1,646 @@
+use anyhow::Result;
+use collections::BTreeMap;
+use futures::{FutureExt, StreamExt, future::BoxFuture};
+use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
+use http_client::HttpClient;
+use language_model::{
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var,
+};
+use opencode::{ApiProtocol, OPENCODE_API_URL};
+pub use settings::OpenCodeAvailableModel 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 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,
+};
+
+const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opencode");
+const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenCode Zen");
+
+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 struct OpenCodeLanguageModelProvider {
+    http_client: Arc<dyn HttpClient>,
+    state: Entity<State>,
+}
+
+pub struct State {
+    api_key_state: ApiKeyState,
+}
+
+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 api_url = OpenCodeLanguageModelProvider::api_url(cx);
+        self.api_key_state
+            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+    }
+
+    fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let api_url = OpenCodeLanguageModelProvider::api_url(cx);
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+    }
+}
+
+impl OpenCodeLanguageModelProvider {
+    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+        let state = cx.new(|cx| {
+            cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let api_url = Self::api_url(cx);
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                cx.notify();
+            })
+            .detach();
+            State {
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+            }
+        });
+
+        Self { http_client, state }
+    }
+
+    fn create_language_model(&self, model: opencode::Model) -> Arc<dyn LanguageModel> {
+        Arc::new(OpenCodeLanguageModel {
+            id: LanguageModelId::from(model.id().to_string()),
+            model,
+            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 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>> {
+        Some(self.create_language_model(opencode::Model::default()))
+    }
+
+    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(opencode::Model::default_fast()))
+    }
+
+    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
+        let mut models = BTreeMap::default();
+
+        for model in opencode::Model::iter() {
+            if !matches!(model, opencode::Model::Custom { .. }) {
+                models.insert(model.id().to_string(), model);
+            }
+        }
+
+        for model in &Self::settings(cx).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
+            };
+            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,
+                },
+            );
+        }
+
+        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 OpenCodeLanguageModel {
+    id: LanguageModelId,
+    model: opencode::Model,
+    state: Entity<State>,
+    http_client: Arc<dyn HttpClient>,
+    request_limiter: RateLimiter,
+}
+
+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))
+    }
+
+    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,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            futures::stream::BoxStream<
+                'static,
+                Result<anthropic::Event, anthropic::AnthropicError>,
+            >,
+            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);
+
+        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,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<futures::stream::BoxStream<'static, Result<open_ai::ResponseStreamEvent>>>,
+    > {
+        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();
+        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,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<futures::stream::BoxStream<'static, Result<open_ai::responses::StreamEvent>>>,
+    > {
+        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();
+        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_zen(
+        &self,
+        request: google_ai::GenerateContentRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<futures::stream::BoxStream<'static, Result<google_ai::GenerateContentResponse>>>,
+    > {
+        let http_client = self.http_client.clone();
+        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_zen(
+                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(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_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() != ApiProtocol::Google
+            }
+        }
+    }
+
+    fn telemetry_id(&self) -> String {
+        format!("opencode/{}", 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 count_tokens(
+        &self,
+        request: LanguageModelRequest,
+        cx: &App,
+    ) -> BoxFuture<'static, Result<u64>> {
+        cx.background_spawn(async move {
+            let messages = request
+                .messages
+                .into_iter()
+                .map(|message| tiktoken_rs::ChatCompletionRequestMessage {
+                    role: match message.role {
+                        Role::User => "user".into(),
+                        Role::Assistant => "assistant".into(),
+                        Role::System => "system".into(),
+                    },
+                    content: Some(message.string_contents()),
+                    name: None,
+                    function_call: None,
+                })
+                .collect::<Vec<_>>();
+
+            tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages).map(|tokens| tokens as u64)
+        })
+        .boxed()
+    }
+
+    fn stream_completion(
+        &self,
+        request: LanguageModelRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            futures::stream::BoxStream<
+                'static,
+                Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+            >,
+            LanguageModelCompletionError,
+        >,
+    > {
+        match self.model.protocol() {
+            ApiProtocol::Anthropic => {
+                let anthropic_request = into_anthropic(
+                    request,
+                    self.model.id().to_string(),
+                    1.0,
+                    self.model.max_output_tokens().unwrap_or(8192),
+                    anthropic::AnthropicModelMode::Default,
+                );
+                let stream = self.stream_anthropic(anthropic_request, cx);
+                async move {
+                    let mapper = AnthropicEventMapper::new();
+                    Ok(mapper.map_stream(stream.await?).boxed())
+                }
+                .boxed()
+            }
+            ApiProtocol::OpenAiChat => {
+                let openai_request = into_open_ai(
+                    request,
+                    self.model.id(),
+                    false,
+                    false,
+                    self.model.max_output_tokens(),
+                    None,
+                );
+                let stream = self.stream_openai_chat(openai_request, cx);
+                async move {
+                    let mapper = OpenAiEventMapper::new();
+                    Ok(mapper.map_stream(stream.await?).boxed())
+                }
+                .boxed()
+            }
+            ApiProtocol::OpenAiResponses => {
+                let response_request = into_open_ai_response(
+                    request,
+                    self.model.id(),
+                    false,
+                    false,
+                    self.model.max_output_tokens(),
+                    None,
+                );
+                let stream = self.stream_openai_response(response_request, 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_zen(google_request, 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 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 Zen 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 Zen Console",
+                                    "https://opencode.ai/zen",
+                                )),
+                        )
+                        .child(ListBulletItem::new(
+                            "Paste your API key below and hit enter to start using OpenCode Zen",
+                        )),
+                )
+                .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 {
+            v_flex().size_full().child(api_key_section).into_any()
+        }
+    }
+}

crates/language_models/src/settings.rs 🔗

@@ -8,7 +8,8 @@ use crate::provider::{
     deepseek::DeepSeekSettings, google::GoogleSettings, lmstudio::LmStudioSettings,
     mistral::MistralSettings, ollama::OllamaSettings, open_ai::OpenAiSettings,
     open_ai_compatible::OpenAiCompatibleSettings, open_router::OpenRouterSettings,
-    vercel::VercelSettings, vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings,
+    opencode::OpenCodeSettings, vercel::VercelSettings, vercel_ai_gateway::VercelAiGatewaySettings,
+    x_ai::XAiSettings,
 };
 
 #[derive(Debug, RegisterSetting)]
@@ -20,6 +21,7 @@ pub struct AllLanguageModelSettings {
     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>,
@@ -41,6 +43,7 @@ impl settings::Settings for AllLanguageModelSettings {
         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();
@@ -85,6 +88,10 @@ impl settings::Settings for AllLanguageModelSettings {
                 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(),
+            },
             open_router: OpenRouterSettings {
                 api_url: open_router.api_url.unwrap(),
                 available_models: open_router.available_models.unwrap_or_default(),

crates/opencode/Cargo.toml 🔗

@@ -0,0 +1,27 @@
+[package]
+name = "opencode"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/opencode.rs"
+test = false
+
+[features]
+default = []
+schemars = ["dep:schemars"]
+
+[dependencies]
+anyhow.workspace = true
+futures.workspace = true
+google_ai.workspace = true
+http_client.workspace = true
+schemars = { workspace = true, optional = true }
+serde.workspace = true
+serde_json.workspace = true
+strum.workspace = true

crates/opencode/src/opencode.rs 🔗

@@ -0,0 +1,453 @@
+use anyhow::{Result, anyhow};
+use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
+use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
+use serde::{Deserialize, Serialize};
+use strum::EnumIter;
+
+pub const OPENCODE_API_URL: &str = "https://opencode.ai/zen";
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[serde(rename_all = "snake_case")]
+pub enum ApiProtocol {
+    #[default]
+    Anthropic,
+    OpenAiResponses,
+    OpenAiChat,
+    Google,
+}
+
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
+pub enum Model {
+    // -- Anthropic protocol models --
+    #[serde(rename = "claude-opus-4-6")]
+    ClaudeOpus4_6,
+    #[serde(rename = "claude-opus-4-5")]
+    ClaudeOpus4_5,
+    #[serde(rename = "claude-opus-4-1")]
+    ClaudeOpus4_1,
+    #[default]
+    #[serde(rename = "claude-sonnet-4-6")]
+    ClaudeSonnet4_6,
+    #[serde(rename = "claude-sonnet-4-5")]
+    ClaudeSonnet4_5,
+    #[serde(rename = "claude-sonnet-4")]
+    ClaudeSonnet4,
+    #[serde(rename = "claude-haiku-4-5")]
+    ClaudeHaiku4_5,
+    #[serde(rename = "claude-3-5-haiku")]
+    Claude3_5Haiku,
+
+    // -- OpenAI Responses API models --
+    #[serde(rename = "gpt-5.4")]
+    Gpt5_4,
+    #[serde(rename = "gpt-5.4-pro")]
+    Gpt5_4Pro,
+    #[serde(rename = "gpt-5.4-mini")]
+    Gpt5_4Mini,
+    #[serde(rename = "gpt-5.4-nano")]
+    Gpt5_4Nano,
+    #[serde(rename = "gpt-5.3-codex")]
+    Gpt5_3Codex,
+    #[serde(rename = "gpt-5.3-codex-spark")]
+    Gpt5_3Spark,
+    #[serde(rename = "gpt-5.2")]
+    Gpt5_2,
+    #[serde(rename = "gpt-5.2-codex")]
+    Gpt5_2Codex,
+    #[serde(rename = "gpt-5.1")]
+    Gpt5_1,
+    #[serde(rename = "gpt-5.1-codex")]
+    Gpt5_1Codex,
+    #[serde(rename = "gpt-5.1-codex-max")]
+    Gpt5_1CodexMax,
+    #[serde(rename = "gpt-5.1-codex-mini")]
+    Gpt5_1CodexMini,
+    #[serde(rename = "gpt-5")]
+    Gpt5,
+    #[serde(rename = "gpt-5-codex")]
+    Gpt5Codex,
+    #[serde(rename = "gpt-5-nano")]
+    Gpt5Nano,
+
+    // -- Google protocol models --
+    #[serde(rename = "gemini-3.1-pro")]
+    Gemini3_1Pro,
+    #[serde(rename = "gemini-3-flash")]
+    Gemini3Flash,
+
+    // -- OpenAI Chat Completions protocol models --
+    #[serde(rename = "minimax-m2.5")]
+    MiniMaxM2_5,
+    #[serde(rename = "minimax-m2.5-free")]
+    MiniMaxM2_5Free,
+    #[serde(rename = "glm-5")]
+    Glm5,
+    #[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 = "big-pickle")]
+    BigPickle,
+    #[serde(rename = "nemotron-3-super-free")]
+    Nemotron3SuperFree,
+
+    // -- Custom model --
+    #[serde(rename = "custom")]
+    Custom {
+        name: String,
+        display_name: Option<String>,
+        max_tokens: u64,
+        max_output_tokens: Option<u64>,
+        protocol: ApiProtocol,
+    },
+}
+
+impl Model {
+    pub fn default_fast() -> Self {
+        Self::ClaudeHaiku4_5
+    }
+
+    pub fn id(&self) -> &str {
+        match self {
+            Self::ClaudeOpus4_6 => "claude-opus-4-6",
+            Self::ClaudeOpus4_5 => "claude-opus-4-5",
+            Self::ClaudeOpus4_1 => "claude-opus-4-1",
+            Self::ClaudeSonnet4_6 => "claude-sonnet-4-6",
+            Self::ClaudeSonnet4_5 => "claude-sonnet-4-5",
+            Self::ClaudeSonnet4 => "claude-sonnet-4",
+            Self::ClaudeHaiku4_5 => "claude-haiku-4-5",
+            Self::Claude3_5Haiku => "claude-3-5-haiku",
+
+            Self::Gpt5_4 => "gpt-5.4",
+            Self::Gpt5_4Pro => "gpt-5.4-pro",
+            Self::Gpt5_4Mini => "gpt-5.4-mini",
+            Self::Gpt5_4Nano => "gpt-5.4-nano",
+            Self::Gpt5_3Codex => "gpt-5.3-codex",
+            Self::Gpt5_3Spark => "gpt-5.3-codex-spark",
+            Self::Gpt5_2 => "gpt-5.2",
+            Self::Gpt5_2Codex => "gpt-5.2-codex",
+            Self::Gpt5_1 => "gpt-5.1",
+            Self::Gpt5_1Codex => "gpt-5.1-codex",
+            Self::Gpt5_1CodexMax => "gpt-5.1-codex-max",
+            Self::Gpt5_1CodexMini => "gpt-5.1-codex-mini",
+            Self::Gpt5 => "gpt-5",
+            Self::Gpt5Codex => "gpt-5-codex",
+            Self::Gpt5Nano => "gpt-5-nano",
+
+            Self::Gemini3_1Pro => "gemini-3.1-pro",
+            Self::Gemini3Flash => "gemini-3-flash",
+
+            Self::MiniMaxM2_5 => "minimax-m2.5",
+            Self::MiniMaxM2_5Free => "minimax-m2.5-free",
+            Self::Glm5 => "glm-5",
+            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::BigPickle => "big-pickle",
+            Self::Nemotron3SuperFree => "nemotron-3-super-free",
+
+            Self::Custom { name, .. } => name,
+        }
+    }
+
+    pub fn display_name(&self) -> &str {
+        match self {
+            Self::ClaudeOpus4_6 => "Claude Opus 4.6",
+            Self::ClaudeOpus4_5 => "Claude Opus 4.5",
+            Self::ClaudeOpus4_1 => "Claude Opus 4.1",
+            Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6",
+            Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5",
+            Self::ClaudeSonnet4 => "Claude Sonnet 4",
+            Self::ClaudeHaiku4_5 => "Claude Haiku 4.5",
+            Self::Claude3_5Haiku => "Claude Haiku 3.5",
+
+            Self::Gpt5_4 => "GPT 5.4",
+            Self::Gpt5_4Pro => "GPT 5.4 Pro",
+            Self::Gpt5_4Mini => "GPT 5.4 Mini",
+            Self::Gpt5_4Nano => "GPT 5.4 Nano",
+            Self::Gpt5_3Codex => "GPT 5.3 Codex",
+            Self::Gpt5_3Spark => "GPT 5.3 Codex Spark",
+            Self::Gpt5_2 => "GPT 5.2",
+            Self::Gpt5_2Codex => "GPT 5.2 Codex",
+            Self::Gpt5_1 => "GPT 5.1",
+            Self::Gpt5_1Codex => "GPT 5.1 Codex",
+            Self::Gpt5_1CodexMax => "GPT 5.1 Codex Max",
+            Self::Gpt5_1CodexMini => "GPT 5.1 Codex Mini",
+            Self::Gpt5 => "GPT 5",
+            Self::Gpt5Codex => "GPT 5 Codex",
+            Self::Gpt5Nano => "GPT 5 Nano",
+
+            Self::Gemini3_1Pro => "Gemini 3.1 Pro",
+            Self::Gemini3Flash => "Gemini 3 Flash",
+
+            Self::MiniMaxM2_5 => "MiniMax M2.5",
+            Self::MiniMaxM2_5Free => "MiniMax M2.5 Free",
+            Self::Glm5 => "GLM 5",
+            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::BigPickle => "Big Pickle",
+            Self::Nemotron3SuperFree => "Nemotron 3 Super Free",
+
+            Self::Custom {
+                name, display_name, ..
+            } => display_name.as_deref().unwrap_or(name),
+        }
+    }
+
+    pub fn protocol(&self) -> ApiProtocol {
+        match self {
+            Self::ClaudeOpus4_6
+            | Self::ClaudeOpus4_5
+            | Self::ClaudeOpus4_1
+            | Self::ClaudeSonnet4_6
+            | Self::ClaudeSonnet4_5
+            | Self::ClaudeSonnet4
+            | Self::ClaudeHaiku4_5
+            | Self::Claude3_5Haiku => ApiProtocol::Anthropic,
+
+            Self::Gpt5_4
+            | Self::Gpt5_4Pro
+            | Self::Gpt5_4Mini
+            | Self::Gpt5_4Nano
+            | Self::Gpt5_3Codex
+            | Self::Gpt5_3Spark
+            | Self::Gpt5_2
+            | Self::Gpt5_2Codex
+            | Self::Gpt5_1
+            | Self::Gpt5_1Codex
+            | Self::Gpt5_1CodexMax
+            | Self::Gpt5_1CodexMini
+            | Self::Gpt5
+            | Self::Gpt5Codex
+            | Self::Gpt5Nano => ApiProtocol::OpenAiResponses,
+
+            Self::Gemini3_1Pro | Self::Gemini3Flash => ApiProtocol::Google,
+
+            Self::MiniMaxM2_5
+            | Self::MiniMaxM2_5Free
+            | Self::Glm5
+            | Self::KimiK2_5
+            | Self::MimoV2ProFree
+            | Self::MimoV2OmniFree
+            | Self::MimoV2FlashFree
+            | Self::TrinityLargePreviewFree
+            | Self::BigPickle
+            | Self::Nemotron3SuperFree => ApiProtocol::OpenAiChat,
+
+            Self::Custom { protocol, .. } => *protocol,
+        }
+    }
+
+    pub fn max_token_count(&self) -> u64 {
+        match self {
+            // Anthropic models
+            Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000,
+            Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 => 200_000,
+            Self::ClaudeOpus4_1 => 200_000,
+            Self::ClaudeHaiku4_5 => 200_000,
+            Self::Claude3_5Haiku => 200_000,
+
+            // OpenAI models
+            Self::Gpt5_4 | Self::Gpt5_4Pro => 1_050_000,
+            Self::Gpt5_4Mini | Self::Gpt5_4Nano => 400_000,
+            Self::Gpt5_3Codex => 400_000,
+            Self::Gpt5_3Spark => 128_000,
+            Self::Gpt5_2 | Self::Gpt5_2Codex => 400_000,
+            Self::Gpt5_1 | Self::Gpt5_1Codex | Self::Gpt5_1CodexMax | Self::Gpt5_1CodexMini => {
+                400_000
+            }
+            Self::Gpt5 | Self::Gpt5Codex | Self::Gpt5Nano => 400_000,
+
+            // Google models
+            Self::Gemini3_1Pro => 1_048_576,
+            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::BigPickle => 200_000,
+            Self::Nemotron3SuperFree => 262_144,
+
+            Self::Custom { max_tokens, .. } => *max_tokens,
+        }
+    }
+
+    pub fn max_output_tokens(&self) -> Option<u64> {
+        match self {
+            // Anthropic models
+            Self::ClaudeOpus4_6 => Some(128_000),
+            Self::ClaudeSonnet4_6 => Some(64_000),
+            Self::ClaudeOpus4_5
+            | Self::ClaudeOpus4_1
+            | Self::ClaudeSonnet4_5
+            | Self::ClaudeSonnet4
+            | Self::ClaudeHaiku4_5 => Some(64_000),
+            Self::Claude3_5Haiku => Some(8_192),
+
+            // OpenAI models
+            Self::Gpt5_4
+            | Self::Gpt5_4Pro
+            | Self::Gpt5_4Mini
+            | Self::Gpt5_4Nano
+            | Self::Gpt5_3Codex
+            | Self::Gpt5_3Spark
+            | Self::Gpt5_2
+            | Self::Gpt5_2Codex
+            | Self::Gpt5_1
+            | Self::Gpt5_1Codex
+            | Self::Gpt5_1CodexMax
+            | Self::Gpt5_1CodexMini
+            | Self::Gpt5
+            | Self::Gpt5Codex
+            | Self::Gpt5Nano => Some(128_000),
+
+            // Google models
+            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::Custom {
+                max_output_tokens, ..
+            } => *max_output_tokens,
+        }
+    }
+
+    pub fn supports_tools(&self) -> bool {
+        true
+    }
+
+    pub fn supports_images(&self) -> bool {
+        match self {
+            // Anthropic models support images
+            Self::ClaudeOpus4_6
+            | Self::ClaudeOpus4_5
+            | Self::ClaudeOpus4_1
+            | Self::ClaudeSonnet4_6
+            | Self::ClaudeSonnet4_5
+            | Self::ClaudeSonnet4
+            | Self::ClaudeHaiku4_5
+            | Self::Claude3_5Haiku => true,
+
+            // OpenAI models support images
+            Self::Gpt5_4
+            | Self::Gpt5_4Pro
+            | Self::Gpt5_4Mini
+            | Self::Gpt5_4Nano
+            | Self::Gpt5_3Codex
+            | Self::Gpt5_3Spark
+            | Self::Gpt5_2
+            | Self::Gpt5_2Codex
+            | Self::Gpt5_1
+            | Self::Gpt5_1Codex
+            | Self::Gpt5_1CodexMax
+            | Self::Gpt5_1CodexMini
+            | Self::Gpt5
+            | Self::Gpt5Codex
+            | Self::Gpt5Nano => true,
+
+            // Google models support images
+            Self::Gemini3_1Pro | Self::Gemini3Flash => true,
+
+            // OpenAI-compatible models — conservative default
+            Self::MiniMaxM2_5
+            | Self::MiniMaxM2_5Free
+            | Self::Glm5
+            | Self::KimiK2_5
+            | Self::MimoV2ProFree
+            | Self::MimoV2OmniFree
+            | Self::MimoV2FlashFree
+            | Self::TrinityLargePreviewFree
+            | Self::BigPickle
+            | Self::Nemotron3SuperFree => false,
+
+            Self::Custom { protocol, .. } => matches!(
+                protocol,
+                ApiProtocol::Anthropic
+                    | ApiProtocol::OpenAiResponses
+                    | ApiProtocol::OpenAiChat
+                    | ApiProtocol::Google
+            ),
+        }
+    }
+}
+
+/// Stream generate content for Google models via OpenCode Zen.
+///
+/// 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(
+    client: &dyn HttpClient,
+    api_url: &str,
+    api_key: &str,
+    request: google_ai::GenerateContentRequest,
+) -> Result<BoxStream<'static, Result<google_ai::GenerateContentResponse>>> {
+    let api_key = api_key.trim();
+
+    let model_id = &request.model.model_id;
+
+    let uri = format!("{api_url}/v1/models/{model_id}:streamGenerateContent?alt=sse");
+
+    let request_builder = HttpRequest::builder()
+        .method(Method::POST)
+        .uri(uri)
+        .header("Content-Type", "application/json")
+        .header("Authorization", format!("Bearer {api_key}"));
+
+    let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
+    let mut response = client.send(request).await?;
+    if response.status().is_success() {
+        let reader = BufReader::new(response.into_body());
+        Ok(reader
+            .lines()
+            .filter_map(|line| async move {
+                match line {
+                    Ok(line) => {
+                        if let Some(line) = line.strip_prefix("data: ") {
+                            match serde_json::from_str(line) {
+                                Ok(response) => Some(Ok(response)),
+                                Err(error) => {
+                                    Some(Err(anyhow!("Error parsing JSON: {error:?}\n{line:?}")))
+                                }
+                            }
+                        } else {
+                            None
+                        }
+                    }
+                    Err(error) => Some(Err(anyhow!(error))),
+                }
+            })
+            .boxed())
+    } else {
+        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: {}",
+            response.status(),
+            text
+        ))
+    }
+}

crates/settings_content/src/language_model.rs 🔗

@@ -16,6 +16,7 @@ pub struct AllLanguageModelSettingsContent {
     pub lmstudio: Option<LmStudioSettingsContent>,
     pub mistral: Option<MistralSettingsContent>,
     pub ollama: Option<OllamaSettingsContent>,
+    pub opencode: Option<OpenCodeSettingsContent>,
     pub open_router: Option<OpenRouterSettingsContent>,
     pub openai: Option<OpenAiSettingsContent>,
     pub openai_compatible: Option<HashMap<Arc<str>, OpenAiCompatibleSettingsContent>>,
@@ -144,6 +145,24 @@ impl Default for KeepAlive {
     }
 }
 
+#[with_fallible_options]
+#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
+pub struct OpenCodeSettingsContent {
+    pub api_url: Option<String>,
+    pub available_models: Option<Vec<OpenCodeAvailableModel>>,
+}
+
+#[with_fallible_options]
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct OpenCodeAvailableModel {
+    pub name: String,
+    pub display_name: Option<String>,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option<u64>,
+    /// The API protocol to use for this model: "anthropic", "openai_responses", "openai_chat", or "google".
+    pub protocol: String,
+}
+
 #[with_fallible_options]
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
 pub struct LmStudioSettingsContent {