Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:32:17.583Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

opencode.rs

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