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}