Skip to repository content1008 lines · 36.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:29:25.317Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
opencode.rs
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