opencode.rs

  1use anyhow::Result;
  2use collections::BTreeMap;
  3use credentials_provider::CredentialsProvider;
  4use fs::Fs;
  5use futures::{FutureExt, StreamExt, future::BoxFuture};
  6use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
  7use http_client::{AsyncBody, HttpClient, http};
  8use language_model::{
  9    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
 10    LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
 11    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
 12    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
 13    ReasoningEffort, env_var,
 14};
 15use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription};
 16pub use settings::OpenCodeAvailableModel as AvailableModel;
 17use settings::{Settings, SettingsStore, update_settings_file};
 18use std::sync::{Arc, LazyLock};
 19use strum::IntoEnumIterator;
 20use ui::{
 21    Banner, ButtonLink, ConfiguredApiCard, List, ListBulletItem, Severity, Switch,
 22    SwitchLabelPosition, ToggleState, prelude::*,
 23};
 24use ui_input::InputField;
 25use util::ResultExt;
 26
 27use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic};
 28use crate::provider::google::{GoogleEventMapper, into_google};
 29use crate::provider::open_ai::{
 30    OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response,
 31};
 32
 33fn normalize_reasoning_effort(effort: &str) -> Option<ReasoningEffort> {
 34    match effort.trim().to_ascii_lowercase().as_str() {
 35        "minimal" => Some(ReasoningEffort::Minimal),
 36        "low" => Some(ReasoningEffort::Low),
 37        "medium" => Some(ReasoningEffort::Medium),
 38        "high" => Some(ReasoningEffort::High),
 39        "max" | "xhigh" => Some(ReasoningEffort::XHigh),
 40        _ => None,
 41    }
 42}
 43
 44fn reasoning_effort_display(effort: ReasoningEffort) -> (&'static str, &'static str) {
 45    match effort {
 46        ReasoningEffort::Minimal => ("Minimal", "minimal"),
 47        ReasoningEffort::Low => ("Low", "low"),
 48        ReasoningEffort::Medium => ("Medium", "medium"),
 49        ReasoningEffort::High => ("High", "high"),
 50        ReasoningEffort::XHigh => ("Max", "max"),
 51    }
 52}
 53
 54const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opencode");
 55const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenCode");
 56
 57const API_KEY_ENV_VAR_NAME: &str = "OPENCODE_API_KEY";
 58static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
 59
 60#[derive(Default, Clone, Debug, PartialEq)]
 61pub struct OpenCodeSettings {
 62    pub api_url: String,
 63    pub available_models: Vec<AvailableModel>,
 64    pub show_zen_models: bool,
 65    pub show_go_models: bool,
 66    pub show_free_models: bool,
 67}
 68
 69pub struct OpenCodeLanguageModelProvider {
 70    http_client: Arc<dyn HttpClient>,
 71    state: Entity<State>,
 72}
 73
 74pub struct State {
 75    api_key_state: ApiKeyState,
 76    credentials_provider: Arc<dyn CredentialsProvider>,
 77}
 78
 79impl State {
 80    fn is_authenticated(&self) -> bool {
 81        self.api_key_state.has_key()
 82    }
 83
 84    fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
 85        let credentials_provider = self.credentials_provider.clone();
 86        let api_url = OpenCodeLanguageModelProvider::api_url(cx);
 87        self.api_key_state.store(
 88            api_url,
 89            api_key,
 90            |this| &mut this.api_key_state,
 91            credentials_provider,
 92            cx,
 93        )
 94    }
 95
 96    fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
 97        let credentials_provider = self.credentials_provider.clone();
 98        let api_url = OpenCodeLanguageModelProvider::api_url(cx);
 99        self.api_key_state.load_if_needed(
100            api_url,
101            |this| &mut this.api_key_state,
102            credentials_provider,
103            cx,
104        )
105    }
106}
107
108impl OpenCodeLanguageModelProvider {
109    pub fn new(
110        http_client: Arc<dyn HttpClient>,
111        credentials_provider: Arc<dyn CredentialsProvider>,
112        cx: &mut App,
113    ) -> Self {
114        let state = cx.new(|cx| {
115            cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
116                let credentials_provider = this.credentials_provider.clone();
117                let api_url = Self::api_url(cx);
118                this.api_key_state.handle_url_change(
119                    api_url,
120                    |this| &mut this.api_key_state,
121                    credentials_provider,
122                    cx,
123                );
124                cx.notify();
125            })
126            .detach();
127            State {
128                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
129                credentials_provider,
130            }
131        });
132
133        Self { http_client, state }
134    }
135
136    fn create_language_model(
137        &self,
138        model: opencode::Model,
139        subscription: OpenCodeSubscription,
140    ) -> Arc<dyn LanguageModel> {
141        let id_str = format!("{}/{}", subscription.id_prefix(), model.id());
142        Arc::new(OpenCodeLanguageModel {
143            id: LanguageModelId::from(id_str),
144            model,
145            subscription,
146            state: self.state.clone(),
147            http_client: self.http_client.clone(),
148            request_limiter: RateLimiter::new(4),
149        })
150    }
151
152    pub fn settings(cx: &App) -> &OpenCodeSettings {
153        &crate::AllLanguageModelSettings::get_global(cx).opencode
154    }
155
156    fn subscription_enabled(subscription: OpenCodeSubscription, cx: &App) -> bool {
157        let settings = Self::settings(cx);
158        match subscription {
159            OpenCodeSubscription::Zen => settings.show_zen_models,
160            OpenCodeSubscription::Go => settings.show_go_models,
161            OpenCodeSubscription::Free => settings.show_free_models,
162        }
163    }
164
165    fn api_url(cx: &App) -> SharedString {
166        let api_url = &Self::settings(cx).api_url;
167        if api_url.is_empty() {
168            OPENCODE_API_URL.into()
169        } else {
170            SharedString::new(api_url.as_str())
171        }
172    }
173}
174
175impl LanguageModelProviderState for OpenCodeLanguageModelProvider {
176    type ObservableEntity = State;
177
178    fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
179        Some(self.state.clone())
180    }
181}
182
183impl LanguageModelProvider for OpenCodeLanguageModelProvider {
184    fn id(&self) -> LanguageModelProviderId {
185        PROVIDER_ID
186    }
187
188    fn name(&self) -> LanguageModelProviderName {
189        PROVIDER_NAME
190    }
191
192    fn icon(&self) -> IconOrSvg {
193        IconOrSvg::Icon(IconName::AiOpenCode)
194    }
195
196    fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
197        if Self::subscription_enabled(OpenCodeSubscription::Go, cx) {
198            // If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go
199            Some(
200                self.create_language_model(opencode::Model::default_go(), OpenCodeSubscription::Go),
201            )
202        } else if Self::subscription_enabled(OpenCodeSubscription::Zen, cx) {
203            Some(self.create_language_model(opencode::Model::default(), OpenCodeSubscription::Zen))
204        } else if Self::subscription_enabled(OpenCodeSubscription::Free, cx) {
205            Some(
206                self.create_language_model(
207                    opencode::Model::default_free(),
208                    OpenCodeSubscription::Free,
209                ),
210            )
211        } else {
212            None
213        }
214    }
215
216    fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
217        if Self::subscription_enabled(OpenCodeSubscription::Go, cx) {
218            // If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go
219            Some(self.create_language_model(
220                opencode::Model::default_go_fast(),
221                OpenCodeSubscription::Go,
222            ))
223        } else if Self::subscription_enabled(OpenCodeSubscription::Zen, cx) {
224            Some(
225                self.create_language_model(
226                    opencode::Model::default_fast(),
227                    OpenCodeSubscription::Zen,
228                ),
229            )
230        } else if Self::subscription_enabled(OpenCodeSubscription::Free, cx) {
231            Some(self.create_language_model(
232                opencode::Model::default_free_fast(),
233                OpenCodeSubscription::Free,
234            ))
235        } else {
236            None
237        }
238    }
239
240    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
241        let mut models: BTreeMap<String, (opencode::Model, OpenCodeSubscription)> =
242            BTreeMap::default();
243        let settings = Self::settings(cx);
244
245        for model in opencode::Model::iter() {
246            if matches!(model, opencode::Model::Custom { .. }) {
247                continue;
248            }
249            for &subscription in model.available_subscriptions() {
250                if Self::subscription_enabled(subscription, cx) {
251                    let key = format!("{}/{}", subscription.id_prefix(), model.id());
252                    models.insert(key, (model.clone(), subscription));
253                }
254            }
255        }
256
257        for model in &settings.available_models {
258            let protocol = match model.protocol.as_str() {
259                "anthropic" => ApiProtocol::Anthropic,
260                "openai_responses" => ApiProtocol::OpenAiResponses,
261                "openai_chat" => ApiProtocol::OpenAiChat,
262                "google" => ApiProtocol::Google,
263                _ => ApiProtocol::OpenAiChat, // default fallback
264            };
265            let subscription = match model.subscription {
266                Some(settings::OpenCodeModelSubscription::Go) => OpenCodeSubscription::Go,
267                Some(settings::OpenCodeModelSubscription::Free) => OpenCodeSubscription::Free,
268                Some(settings::OpenCodeModelSubscription::Zen) | None => OpenCodeSubscription::Zen,
269            };
270            if !Self::subscription_enabled(subscription, cx) {
271                continue;
272            }
273            let custom_model = opencode::Model::Custom {
274                name: model.name.clone(),
275                display_name: model.display_name.clone(),
276                max_tokens: model.max_tokens,
277                max_output_tokens: model.max_output_tokens,
278                protocol,
279                reasoning_effort_levels: model.reasoning_effort_levels.clone(),
280                custom_model_api_url: model.custom_model_api_url.clone(),
281            };
282            let key = format!("{}/{}", subscription.id_prefix(), model.name);
283            models.insert(key, (custom_model, subscription));
284        }
285
286        models
287            .into_values()
288            .map(|(model, subscription)| self.create_language_model(model, subscription))
289            .collect()
290    }
291
292    fn is_authenticated(&self, cx: &App) -> bool {
293        self.state.read(cx).is_authenticated()
294    }
295
296    fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
297        self.state.update(cx, |state, cx| state.authenticate(cx))
298    }
299
300    fn configuration_view(
301        &self,
302        _target_agent: language_model::ConfigurationViewTargetAgent,
303        window: &mut Window,
304        cx: &mut App,
305    ) -> AnyView {
306        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
307            .into()
308    }
309
310    fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
311        self.state
312            .update(cx, |state, cx| state.set_api_key(None, cx))
313    }
314}
315
316pub struct OpenCodeLanguageModel {
317    id: LanguageModelId,
318    model: opencode::Model,
319    subscription: OpenCodeSubscription,
320    state: Entity<State>,
321    http_client: Arc<dyn HttpClient>,
322    request_limiter: RateLimiter,
323}
324
325struct InjectHeaderClient {
326    inner: Arc<dyn HttpClient>,
327    name: http::HeaderName,
328    value: http::HeaderValue,
329}
330
331impl HttpClient for InjectHeaderClient {
332    fn user_agent(&self) -> Option<&http::HeaderValue> {
333        self.inner.user_agent()
334    }
335    fn proxy(&self) -> Option<&http_client::Url> {
336        self.inner.proxy()
337    }
338    fn send(
339        &self,
340        mut req: http::Request<AsyncBody>,
341    ) -> futures::future::BoxFuture<'static, anyhow::Result<http::Response<AsyncBody>>> {
342        req.headers_mut()
343            .insert(self.name.clone(), self.value.clone());
344        self.inner.send(req)
345    }
346}
347
348impl OpenCodeLanguageModel {
349    fn base_api_url(&self, cx: &AsyncApp) -> SharedString {
350        // Custom models can override the API URL
351        if let opencode::Model::Custom {
352            custom_model_api_url: Some(url),
353            ..
354        } = &self.model
355        {
356            if !url.is_empty() {
357                return url.clone().into();
358            }
359        }
360
361        // Combine base URL with subscription path suffix
362        let base = self
363            .state
364            .read_with(cx, |_, cx| OpenCodeLanguageModelProvider::api_url(cx));
365
366        let suffix = self.subscription.api_path_suffix();
367        let base_str = base.as_ref().trim_end_matches('/');
368        format!("{}{}", base_str, suffix).into()
369    }
370
371    fn api_key(&self, cx: &AsyncApp) -> Option<Arc<str>> {
372        self.state.read_with(cx, |state, cx| {
373            let api_url = OpenCodeLanguageModelProvider::api_url(cx);
374            state.api_key_state.key(&api_url)
375        })
376    }
377
378    fn stream_anthropic(
379        &self,
380        request: anthropic::Request,
381        http_client: Arc<dyn HttpClient>,
382        cx: &AsyncApp,
383    ) -> BoxFuture<
384        'static,
385        Result<
386            futures::stream::BoxStream<
387                'static,
388                Result<anthropic::Event, anthropic::AnthropicError>,
389            >,
390            LanguageModelCompletionError,
391        >,
392    > {
393        // Anthropic crate appends /v1/messages to api_url
394        let api_url = self.base_api_url(cx);
395        let api_key = self.api_key(cx);
396
397        let future = self.request_limiter.stream(async move {
398            let Some(api_key) = api_key else {
399                return Err(LanguageModelCompletionError::NoApiKey {
400                    provider: PROVIDER_NAME,
401                });
402            };
403            let request = anthropic::stream_completion(
404                http_client.as_ref(),
405                &api_url,
406                &api_key,
407                request,
408                None,
409            );
410            let response = request.await?;
411            Ok(response)
412        });
413
414        async move { Ok(future.await?.boxed()) }.boxed()
415    }
416
417    fn stream_openai_chat(
418        &self,
419        request: open_ai::Request,
420        http_client: Arc<dyn HttpClient>,
421        cx: &AsyncApp,
422    ) -> BoxFuture<
423        'static,
424        Result<futures::stream::BoxStream<'static, Result<open_ai::ResponseStreamEvent>>>,
425    > {
426        // OpenAI crate appends /chat/completions to api_url, so we pass base + "/v1"
427        let base_url = self.base_api_url(cx);
428        let api_url: SharedString = format!("{base_url}/v1").into();
429        let api_key = self.api_key(cx);
430        let provider_name = PROVIDER_NAME.0.to_string();
431
432        let future = self.request_limiter.stream(async move {
433            let Some(api_key) = api_key else {
434                return Err(LanguageModelCompletionError::NoApiKey {
435                    provider: PROVIDER_NAME,
436                });
437            };
438            let request = open_ai::stream_completion(
439                http_client.as_ref(),
440                &provider_name,
441                &api_url,
442                &api_key,
443                request,
444            );
445            let response = request.await?;
446            Ok(response)
447        });
448
449        async move { Ok(future.await?.boxed()) }.boxed()
450    }
451
452    fn stream_openai_response(
453        &self,
454        request: open_ai::responses::Request,
455        http_client: Arc<dyn HttpClient>,
456        cx: &AsyncApp,
457    ) -> BoxFuture<
458        'static,
459        Result<futures::stream::BoxStream<'static, Result<open_ai::responses::StreamEvent>>>,
460    > {
461        // Responses crate appends /responses to api_url, so we pass base + "/v1"
462        let base_url = self.base_api_url(cx);
463        let api_url: SharedString = format!("{base_url}/v1").into();
464        let api_key = self.api_key(cx);
465        let provider_name = PROVIDER_NAME.0.to_string();
466
467        let future = self.request_limiter.stream(async move {
468            let Some(api_key) = api_key else {
469                return Err(LanguageModelCompletionError::NoApiKey {
470                    provider: PROVIDER_NAME,
471                });
472            };
473            let request = open_ai::responses::stream_response(
474                http_client.as_ref(),
475                &provider_name,
476                &api_url,
477                &api_key,
478                request,
479            );
480            let response = request.await?;
481            Ok(response)
482        });
483
484        async move { Ok(future.await?.boxed()) }.boxed()
485    }
486
487    fn stream_google(
488        &self,
489        request: google_ai::GenerateContentRequest,
490        http_client: Arc<dyn HttpClient>,
491        cx: &AsyncApp,
492    ) -> BoxFuture<
493        'static,
494        Result<futures::stream::BoxStream<'static, Result<google_ai::GenerateContentResponse>>>,
495    > {
496        let api_url = self.base_api_url(cx);
497        let api_key = self.api_key(cx);
498
499        let future = self.request_limiter.stream(async move {
500            let Some(api_key) = api_key else {
501                return Err(LanguageModelCompletionError::NoApiKey {
502                    provider: PROVIDER_NAME,
503                });
504            };
505            let request = opencode::stream_generate_content(
506                http_client.as_ref(),
507                &api_url,
508                &api_key,
509                request,
510            );
511            let response = request.await?;
512            Ok(response)
513        });
514
515        async move { Ok(future.await?.boxed()) }.boxed()
516    }
517}
518
519impl LanguageModel for OpenCodeLanguageModel {
520    fn id(&self) -> LanguageModelId {
521        self.id.clone()
522    }
523
524    fn name(&self) -> LanguageModelName {
525        LanguageModelName::from(format!(
526            "{}: {}",
527            self.subscription.display_name(),
528            self.model.display_name()
529        ))
530    }
531
532    fn provider_id(&self) -> LanguageModelProviderId {
533        PROVIDER_ID
534    }
535
536    fn provider_name(&self) -> LanguageModelProviderName {
537        PROVIDER_NAME
538    }
539
540    fn supports_tools(&self) -> bool {
541        self.model.supports_tools()
542    }
543
544    fn supports_images(&self) -> bool {
545        self.model.supports_images()
546    }
547
548    fn supports_thinking(&self) -> bool {
549        self.model
550            .supported_reasoning_effort_levels()
551            .is_some_and(|levels| !levels.is_empty())
552    }
553
554    fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
555        self.model
556            .supported_reasoning_effort_levels()
557            .map(|levels| {
558                if levels.is_empty() {
559                    return Vec::new();
560                }
561                let default_index = levels.len() - 1;
562                levels
563                    .into_iter()
564                    .enumerate()
565                    .map(|(i, effort)| {
566                        let (name, value) = reasoning_effort_display(effort);
567                        LanguageModelEffortLevel {
568                            name: name.into(),
569                            value: value.into(),
570                            is_default: i == default_index,
571                        }
572                    })
573                    .collect()
574            })
575            .unwrap_or_default()
576    }
577
578    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
579        match choice {
580            LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => true,
581            LanguageModelToolChoice::None => {
582                // Google models don't support None tool choice
583                self.model.protocol(self.subscription) != ApiProtocol::Google
584            }
585        }
586    }
587
588    fn telemetry_id(&self) -> String {
589        format!(
590            "opencode/{}/{}",
591            self.subscription.id_prefix(),
592            self.model.id()
593        )
594    }
595
596    fn max_token_count(&self) -> u64 {
597        self.model.max_token_count()
598    }
599
600    fn max_output_tokens(&self) -> Option<u64> {
601        self.model.max_output_tokens()
602    }
603
604    fn stream_completion(
605        &self,
606        request: LanguageModelRequest,
607        cx: &AsyncApp,
608    ) -> BoxFuture<
609        'static,
610        Result<
611            futures::stream::BoxStream<
612                'static,
613                Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
614            >,
615            LanguageModelCompletionError,
616        >,
617    > {
618        let http_client = if let Some(ref thread_id) = request.thread_id
619            && let Ok(value) = http::HeaderValue::from_str(thread_id)
620        {
621            Arc::new(InjectHeaderClient {
622                inner: self.http_client.clone(),
623                name: http::HeaderName::from_static("x-opencode-session"),
624                value,
625            })
626        } else {
627            self.http_client.clone()
628        };
629
630        match self.model.protocol(self.subscription) {
631            ApiProtocol::Anthropic => {
632                let mode = if self.supports_thinking() && request.thinking_allowed {
633                    anthropic::AnthropicModelMode::AdaptiveThinking
634                } else {
635                    anthropic::AnthropicModelMode::Default
636                };
637                let anthropic_request = into_anthropic(
638                    request,
639                    self.model.id().to_string(),
640                    1.0,
641                    self.model.max_output_tokens().unwrap_or(8192),
642                    mode,
643                );
644                let stream = self.stream_anthropic(anthropic_request, http_client, cx);
645                async move {
646                    let mapper = AnthropicEventMapper::new();
647                    Ok(mapper.map_stream(stream.await?).boxed())
648                }
649                .boxed()
650            }
651            ApiProtocol::OpenAiChat => {
652                let reasoning_effort = if request.thinking_allowed {
653                    request
654                        .thinking_effort
655                        .as_deref()
656                        .and_then(normalize_reasoning_effort)
657                } else {
658                    None
659                };
660                let openai_request = into_open_ai(
661                    request,
662                    self.model.id(),
663                    false,
664                    false,
665                    self.model.max_output_tokens(),
666                    reasoning_effort,
667                    false,
668                );
669                let stream = self.stream_openai_chat(openai_request, http_client, cx);
670                async move {
671                    let mapper = OpenAiEventMapper::new();
672                    Ok(mapper.map_stream(stream.await?).boxed())
673                }
674                .boxed()
675            }
676            ApiProtocol::OpenAiResponses => {
677                let reasoning_effort = if request.thinking_allowed {
678                    request
679                        .thinking_effort
680                        .as_deref()
681                        .and_then(normalize_reasoning_effort)
682                } else {
683                    None
684                };
685                let response_request = into_open_ai_response(
686                    request,
687                    self.model.id(),
688                    false,
689                    false,
690                    self.model.max_output_tokens(),
691                    reasoning_effort,
692                );
693                let stream = self.stream_openai_response(response_request, http_client, cx);
694                async move {
695                    let mapper = OpenAiResponseEventMapper::new();
696                    Ok(mapper.map_stream(stream.await?).boxed())
697                }
698                .boxed()
699            }
700            ApiProtocol::Google => {
701                let google_request = into_google(
702                    request,
703                    self.model.id().to_string(),
704                    google_ai::GoogleModelMode::Default,
705                );
706                let stream = self.stream_google(google_request, http_client, cx);
707                async move {
708                    let mapper = GoogleEventMapper::new();
709                    Ok(mapper.map_stream(stream.await?.boxed()).boxed())
710                }
711                .boxed()
712            }
713        }
714    }
715}
716
717struct ConfigurationView {
718    api_key_editor: Entity<InputField>,
719    state: Entity<State>,
720    load_credentials_task: Option<Task<()>>,
721}
722
723impl ConfigurationView {
724    fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
725        let api_key_editor = cx.new(|cx| {
726            InputField::new(window, cx, "sk-00000000000000000000000000000000").label("API key")
727        });
728
729        cx.observe(&state, |_, _, cx| {
730            cx.notify();
731        })
732        .detach();
733
734        let load_credentials_task = Some(cx.spawn_in(window, {
735            let state = state.clone();
736            async move |this, cx| {
737                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
738                    let _ = task.await;
739                }
740                this.update(cx, |this, cx| {
741                    this.load_credentials_task = None;
742                    cx.notify();
743                })
744                .log_err();
745            }
746        }));
747
748        Self {
749            api_key_editor,
750            state,
751            load_credentials_task,
752        }
753    }
754
755    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
756        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
757        if api_key.is_empty() {
758            return;
759        }
760
761        self.api_key_editor
762            .update(cx, |editor, cx| editor.set_text("", window, cx));
763
764        let state = self.state.clone();
765        cx.spawn_in(window, async move |_, cx| {
766            state
767                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
768                .await
769        })
770        .detach_and_log_err(cx);
771    }
772
773    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
774        self.api_key_editor
775            .update(cx, |editor, cx| editor.set_text("", window, cx));
776
777        let state = self.state.clone();
778        cx.spawn_in(window, async move |_, cx| {
779            state
780                .update(cx, |state, cx| state.set_api_key(None, cx))
781                .await
782        })
783        .detach_and_log_err(cx);
784    }
785
786    fn set_subscription_enabled(
787        &mut self,
788        subscription: OpenCodeSubscription,
789        is_enabled: bool,
790        _window: &mut Window,
791        cx: &mut Context<Self>,
792    ) {
793        let fs = <dyn Fs>::global(cx);
794
795        update_settings_file(fs, cx, move |settings, _| {
796            let opencode_settings = settings
797                .language_models
798                .get_or_insert_default()
799                .opencode
800                .get_or_insert_default();
801
802            match subscription {
803                OpenCodeSubscription::Zen => opencode_settings.show_zen_models = Some(is_enabled),
804                OpenCodeSubscription::Go => opencode_settings.show_go_models = Some(is_enabled),
805                OpenCodeSubscription::Free => opencode_settings.show_free_models = Some(is_enabled),
806            }
807        });
808    }
809
810    fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
811        !self.state.read(cx).is_authenticated()
812    }
813}
814
815impl Render for ConfigurationView {
816    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
817        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
818        let configured_card_label = if env_var_set {
819            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
820        } else {
821            let api_url = OpenCodeLanguageModelProvider::api_url(cx);
822            if api_url == OPENCODE_API_URL {
823                "API key configured".to_string()
824            } else {
825                format!("API key configured for {}", api_url)
826            }
827        };
828
829        let api_key_section = if self.should_render_editor(cx) {
830            v_flex()
831                .on_action(cx.listener(Self::save_api_key))
832                .child(Label::new(
833                    "To use OpenCode models in Zed, you need an API key:",
834                ))
835                .child(
836                    List::new()
837                        .child(
838                            ListBulletItem::new("")
839                                .child(Label::new("Sign in and get your key at"))
840                                .child(ButtonLink::new(
841                                    "OpenCode Console",
842                                    "https://opencode.ai/auth",
843                                )),
844                        )
845                        .child(ListBulletItem::new(
846                            "Paste your API key below and hit enter to start using OpenCode",
847                        )),
848                )
849                .child(self.api_key_editor.clone())
850                .child(
851                    Label::new(format!(
852                        "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
853                    ))
854                    .size(LabelSize::Small)
855                    .color(Color::Muted),
856                )
857                .into_any_element()
858        } else {
859            ConfiguredApiCard::new(configured_card_label)
860                .disabled(env_var_set)
861                .when(env_var_set, |this| {
862                    this.tooltip_label(format!(
863                        "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."
864                    ))
865                })
866                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
867                .into_any_element()
868        };
869
870        if self.load_credentials_task.is_some() {
871            div().child(Label::new("Loading credentials...")).into_any()
872        } else {
873            let settings = OpenCodeLanguageModelProvider::settings(cx);
874            let show_zen = settings.show_zen_models;
875            let show_go = settings.show_go_models;
876            let show_free = settings.show_free_models;
877
878            let subscription_toggles = v_flex()
879                .gap_1()
880                .child(Label::new("Subscriptions:").color(Color::Muted))
881                .child(
882                    Switch::new("opencode-show-zen-models", show_zen.into())
883                        .label("Show Zen models")
884                        .label_position(SwitchLabelPosition::End)
885                        .on_click(cx.listener(|this, state, window, cx| {
886                            this.set_subscription_enabled(
887                                OpenCodeSubscription::Zen,
888                                matches!(state, ToggleState::Selected),
889                                window,
890                                cx,
891                            );
892                        })),
893                )
894                .child(
895                    Switch::new("opencode-show-go-models", show_go.into())
896                        .label("Show Go models")
897                        .label_position(SwitchLabelPosition::End)
898                        .on_click(cx.listener(|this, state, window, cx| {
899                            this.set_subscription_enabled(
900                                OpenCodeSubscription::Go,
901                                matches!(state, ToggleState::Selected),
902                                window,
903                                cx,
904                            );
905                        })),
906                )
907                .child(
908                    Switch::new("opencode-show-free-models", show_free.into())
909                        .label("Show Free models")
910                        .label_position(SwitchLabelPosition::End)
911                        .on_click(cx.listener(|this, state, window, cx| {
912                            this.set_subscription_enabled(
913                                OpenCodeSubscription::Free,
914                                matches!(state, ToggleState::Selected),
915                                window,
916                                cx,
917                            );
918                        })),
919                );
920
921            let no_subscriptions_warning = if !show_zen && !show_go && !show_free {
922                Some(Banner::new().severity(Severity::Warning).child(Label::new(
923                    "No subscriptions enabled. Enable at least one subscription to use OpenCode.",
924                )))
925            } else {
926                None
927            };
928
929            v_flex()
930                .size_full()
931                .gap_2()
932                .child(api_key_section)
933                .child(subscription_toggles)
934                .children(no_subscriptions_warning)
935                .into_any()
936        }
937    }
938}