Skip to repository content1317 lines · 45.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:48:54.629Z 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
llm_providers_page.rs
1use std::{collections::HashSet, sync::Arc};
2
3use editor::Editor;
4use gpui::{AnyView, Entity, Focusable as _, ScrollHandle, prelude::*};
5use language_model::{
6 ApiKeyConfiguration, CreateProviderSettingsView, IconOrSvg, InlineDescription,
7 LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ProviderSettingsView,
8};
9
10use settings::{
11 AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities,
12 AnthropicCompatibleSettingsContent, OpenAiCompatibleAvailableModel,
13 OpenAiCompatibleModelCapabilities, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort,
14};
15use ui::{
16 ButtonLink, Checkbox, ConfiguredApiCard, ContextMenu, Divider, DividerColor, DropdownMenu,
17 DropdownStyle, IconPosition, PopoverMenu, ToggleState, prelude::*,
18};
19use util::ResultExt as _;
20
21use crate::SettingsWindow;
22use crate::components::SettingsInputField;
23
24pub(crate) fn render_llm_providers_page(
25 settings_window: &SettingsWindow,
26 scroll_handle: &ScrollHandle,
27 window: &mut Window,
28 cx: &mut Context<SettingsWindow>,
29) -> AnyElement {
30 let providers = LanguageModelRegistry::read_global(cx).visible_providers();
31
32 v_flex()
33 .id("llm-providers-page")
34 .size_full()
35 .px_8()
36 .pb_16()
37 .track_scroll(scroll_handle)
38 .overflow_y_scroll()
39 .children(
40 providers
41 .iter()
42 .enumerate()
43 .map(|(index, provider)| {
44 render_provider_section(settings_window, provider, index == 0, window, cx)
45 })
46 .collect::<Vec<_>>(),
47 )
48 .into_any_element()
49}
50
51#[derive(Clone, Copy, PartialEq, Eq)]
52pub(crate) enum CompatibleProviderKind {
53 OpenAi,
54 Anthropic,
55}
56
57impl CompatibleProviderKind {
58 fn label(self) -> &'static str {
59 match self {
60 Self::OpenAi => "OpenAI",
61 Self::Anthropic => "Anthropic",
62 }
63 }
64
65 fn default_api_url(self) -> &'static str {
66 match self {
67 Self::OpenAi => "https://api.openai.com/v1",
68 Self::Anthropic => "https://api.anthropic.com",
69 }
70 }
71}
72
73pub(crate) fn render_add_llm_provider_popover(
74 settings_window: &SettingsWindow,
75 _window: &mut Window,
76 cx: &mut Context<SettingsWindow>,
77) -> impl IntoElement {
78 let focus_handle = settings_window
79 .llm_provider_add_focus_handle
80 .clone()
81 .tab_index(0)
82 .tab_stop(true);
83
84 let settings_window = cx.entity().downgrade();
85
86 PopoverMenu::new("add-llm-provider-popover")
87 .trigger(
88 Button::new("add-llm-provider", "Add Provider")
89 .style(ButtonStyle::Outlined)
90 .track_focus(&focus_handle)
91 .label_size(LabelSize::Small)
92 .start_icon(
93 Icon::new(IconName::Plus)
94 .size(IconSize::Small)
95 .color(Color::Muted),
96 ),
97 )
98 .anchor(gpui::Anchor::TopRight)
99 .offset(gpui::Point {
100 x: px(0.0),
101 y: px(2.0),
102 })
103 .menu(move |window, cx| {
104 let settings_window = settings_window.clone();
105 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
106 menu.header("Compatible APIs")
107 .entry("OpenAI", None, {
108 let settings_window = settings_window.clone();
109 move |window, cx| {
110 settings_window
111 .update(cx, |this, cx| {
112 open_llm_provider_form(
113 this,
114 CompatibleProviderKind::OpenAi,
115 window,
116 cx,
117 );
118 })
119 .log_err();
120 }
121 })
122 .entry("Anthropic", None, {
123 let settings_window = settings_window;
124 move |window, cx| {
125 settings_window
126 .update(cx, |this, cx| {
127 open_llm_provider_form(
128 this,
129 CompatibleProviderKind::Anthropic,
130 window,
131 cx,
132 );
133 })
134 .log_err();
135 }
136 })
137 }))
138 })
139}
140
141fn render_provider_section(
142 settings_window: &SettingsWindow,
143 provider: &Arc<dyn LanguageModelProvider>,
144 is_first: bool,
145 window: &mut Window,
146 cx: &mut Context<SettingsWindow>,
147) -> AnyElement {
148 let provider_id = provider.id();
149 let provider_name = provider.name().0;
150
151 let body = match provider.settings_view(cx) {
152 Some(ProviderSettingsView::ApiKey(config)) => {
153 render_api_key_providers_item(provider, provider_name.clone(), config, cx)
154 }
155 Some(ProviderSettingsView::Inline(settings)) => {
156 let view = get_or_create_configuration_view(
157 settings_window,
158 &provider_id,
159 settings.create_view,
160 window,
161 cx,
162 );
163 render_inline_body(
164 provider_name.clone(),
165 settings.title,
166 settings.description,
167 view,
168 )
169 }
170 Some(ProviderSettingsView::SubPage(settings)) => {
171 render_subpage_item(provider, settings.description, cx)
172 }
173 None => div().into_any_element(),
174 };
175
176 v_flex()
177 .min_w_0()
178 .map(|s| if is_first { s.pt_4() } else { s.pt_8() })
179 .gap_1p5()
180 .child(render_provider_header(provider_name, provider.icon(), cx))
181 .child(body)
182 .into_any_element()
183}
184
185/// An icon + name header with a faded divider, mirroring `SettingsSectionHeader`
186/// but able to render providers' external SVG icons.
187fn render_provider_header(
188 provider_name: SharedString,
189 icon: IconOrSvg,
190 cx: &mut Context<SettingsWindow>,
191) -> impl IntoElement {
192 let icon = match icon {
193 IconOrSvg::Svg(path) => Icon::from_external_svg(path),
194 IconOrSvg::Icon(name) => Icon::new(name),
195 }
196 .color(Color::Muted);
197
198 v_flex()
199 .w_full()
200 .gap_1p5()
201 .child(
202 h_flex().gap_1p5().child(icon).child(
203 Label::new(provider_name)
204 .size(LabelSize::Small)
205 .color(Color::Muted)
206 .buffer_font(cx),
207 ),
208 )
209 .child(Divider::horizontal().color(DividerColor::BorderFaded))
210}
211
212fn render_api_key_providers_item(
213 provider: &Arc<dyn LanguageModelProvider>,
214 provider_name: SharedString,
215 config: ApiKeyConfiguration,
216 _cx: &mut Context<SettingsWindow>,
217) -> AnyElement {
218 let provider_id = provider.id();
219 let has_key = config.has_key;
220 let is_from_env_var = config.is_from_env_var;
221 let env_var_name = config.env_var_name;
222 let api_key_url = config.api_key_url;
223
224 if has_key {
225 let configured_label = if is_from_env_var {
226 "API Key Set in Environment Variable"
227 } else {
228 "API Key Configured"
229 };
230 let button_id = format!("reset-api-key-{}", provider_id.0);
231
232 let card = ConfiguredApiCard::new(button_id, configured_label)
233 .button_label("Reset Key")
234 .button_tab_index(0)
235 .disabled(is_from_env_var)
236 .when(is_from_env_var, |this| {
237 this.tooltip_label(format!(
238 "To reset your API key, unset the {env_var_name} environment variable."
239 ))
240 })
241 .on_click({
242 let provider = provider.clone();
243 move |_, _, cx| {
244 provider.set_api_key(None, cx).detach_and_log_err(cx);
245 }
246 })
247 .into_any_element();
248
249 return v_flex().gap_2().child(card).into_any_element();
250 }
251
252 let input_id = format!("{}-api-key-input", provider_id.0);
253 let aria_label = format!("{provider_name} API Key");
254
255 v_flex()
256 .gap_2()
257 .child(
258 h_flex()
259 .pt_2p5()
260 .w_full()
261 .min_w_0()
262 .gap_4()
263 .justify_between()
264 .child(
265 v_flex()
266 .w_full()
267 .min_w_0()
268 .max_w_1_2()
269 .gap_0p5()
270 .child(Label::new("API Key"))
271 .child(
272 h_flex()
273 .w_full()
274 .min_w_0()
275 .flex_wrap()
276 .gap_0p5()
277 .child(
278 Label::new("Visit the")
279 .size(LabelSize::Small)
280 .color(Color::Muted),
281 )
282 .child(
283 ButtonLink::new(
284 format!("{provider_name} dashboard"),
285 api_key_url,
286 )
287 .no_icon(true)
288 .label_size(LabelSize::Small)
289 .label_color(Color::Muted),
290 )
291 .child(
292 Label::new("to generate an API key.")
293 .size(LabelSize::Small)
294 .color(Color::Muted),
295 ),
296 )
297 .child(
298 Label::new(format!(
299 "Or set the {env_var_name} env var and restart Omega for it to take effect."
300 ))
301 .size(LabelSize::XSmall)
302 .color(Color::Muted),
303 ),
304 )
305 .child(
306 SettingsInputField::new(input_id)
307 .tab_index(0)
308 .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
309 .aria_label(aria_label)
310 .on_confirm({
311 let provider = provider.clone();
312 move |api_key, _window, cx| {
313 if let Some(key) = api_key.filter(|key| !key.is_empty()) {
314 provider.set_api_key(Some(key), cx).detach_and_log_err(cx);
315 }
316 }
317 }),
318 ),
319 )
320 .into_any_element()
321}
322
323fn render_inline_body(
324 provider_name: SharedString,
325 title: Option<SharedString>,
326 description: Option<InlineDescription>,
327 view: AnyView,
328) -> AnyElement {
329 if title.is_none() && description.is_none() {
330 return v_flex()
331 .pt_1()
332 .w_full()
333 .min_w_0()
334 .child(view)
335 .into_any_element();
336 }
337
338 h_flex()
339 .pt_2p5()
340 .w_full()
341 .min_w_0()
342 .gap_4()
343 .justify_between()
344 .child(
345 v_flex()
346 .w_full()
347 .min_w_0()
348 .max_w_1_2()
349 .when_some(title, |this, title| this.child(Label::new(title)))
350 .when_some(description, |this, description| {
351 this.child(render_inline_description(provider_name, description))
352 }),
353 )
354 .child(h_flex().flex_none().child(view))
355 .into_any_element()
356}
357
358fn render_subpage_item(
359 provider: &Arc<dyn LanguageModelProvider>,
360 description: Option<InlineDescription>,
361 cx: &mut Context<SettingsWindow>,
362) -> AnyElement {
363 let provider_id = provider.id();
364 let provider_name = provider.name().0;
365
366 h_flex()
367 .pt_2p5()
368 .w_full()
369 .min_w_0()
370 .gap_4()
371 .justify_between()
372 .child(
373 v_flex()
374 .w_full()
375 .min_w_0()
376 .max_w_1_2()
377 .gap_0p5()
378 .child(Label::new("Configure Provider"))
379 .when_some(description, |this, description| {
380 this.child(render_inline_description(provider_name, description))
381 }),
382 )
383 .child(
384 Button::new(format!("configure-{}", provider_id.0), "Configure")
385 .style(ButtonStyle::OutlinedGhost)
386 .size(ButtonSize::Medium)
387 .end_icon(
388 Icon::new(IconName::ChevronRight)
389 .size(IconSize::Small)
390 .color(Color::Muted),
391 )
392 .tab_index(0isize)
393 .on_click(cx.listener(move |this, _, window, cx| {
394 open_provider_configuration(this, provider_id.clone(), window, cx);
395 })),
396 )
397 .into_any_element()
398}
399
400fn render_inline_description(
401 provider_name: SharedString,
402 description: InlineDescription,
403) -> AnyElement {
404 match description {
405 InlineDescription::ApiKeyUrl(url) => h_flex()
406 .gap_0p5()
407 .child(
408 Label::new("To find an API key, visit the")
409 .size(LabelSize::Small)
410 .color(Color::Muted),
411 )
412 .child(
413 ButtonLink::new(format!("{provider_name} dashboard."), url)
414 .label_size(LabelSize::Small),
415 )
416 .into_any_element(),
417 InlineDescription::Text(text) => Label::new(text)
418 .size(LabelSize::Small)
419 .color(Color::Muted)
420 .into_any_element(),
421 }
422}
423
424fn open_provider_configuration(
425 settings_window: &mut SettingsWindow,
426 provider_id: LanguageModelProviderId,
427 window: &mut Window,
428 cx: &mut Context<SettingsWindow>,
429) {
430 let title = LanguageModelRegistry::read_global(cx)
431 .provider(&provider_id)
432 .map(|provider| provider.name().0)
433 .unwrap_or_else(|| provider_id.0.clone());
434
435 settings_window.configuring_provider = Some(provider_id);
436
437 settings_window.push_dynamic_sub_page(
438 title,
439 "Agent Configuration",
440 Some("llm_providers"),
441 true,
442 render_provider_config_sub_page,
443 window,
444 cx,
445 );
446}
447
448fn render_provider_config_sub_page(
449 settings_window: &SettingsWindow,
450 scroll_handle: &ScrollHandle,
451 window: &mut Window,
452 cx: &mut Context<SettingsWindow>,
453) -> AnyElement {
454 let Some(provider_id) = settings_window.configuring_provider.clone() else {
455 return div().into_any_element();
456 };
457 let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&provider_id) else {
458 return div().into_any_element();
459 };
460
461 let Some(create_view) =
462 provider
463 .settings_view(cx)
464 .and_then(|settings_view| match settings_view {
465 ProviderSettingsView::Inline(settings) => Some(settings.create_view),
466 ProviderSettingsView::SubPage(settings) => Some(settings.create_view),
467 ProviderSettingsView::ApiKey(_) => None,
468 })
469 else {
470 return div().into_any_element();
471 };
472 let view =
473 get_or_create_configuration_view(settings_window, &provider_id, create_view, window, cx);
474
475 v_flex()
476 .id("provider-config-sub-page")
477 .size_full()
478 .pt_2p5()
479 .px_8()
480 .pb_16()
481 .track_scroll(scroll_handle)
482 .overflow_y_scroll()
483 .child(view)
484 .into_any_element()
485}
486
487fn get_or_create_configuration_view(
488 settings_window: &SettingsWindow,
489 provider_id: &LanguageModelProviderId,
490 create_view: CreateProviderSettingsView,
491 window: &mut Window,
492 cx: &mut Context<SettingsWindow>,
493) -> AnyView {
494 if let Some(view) = settings_window
495 .provider_configuration_views
496 .get(provider_id)
497 {
498 return view.clone();
499 }
500
501 let view = create_view(window, cx);
502
503 // Store the view for future renders by deferring a mutation
504 let provider_id = provider_id.clone();
505 let view_clone = view.clone();
506 cx.defer_in(window, move |this, _window, _cx| {
507 this.provider_configuration_views
508 .insert(provider_id, view_clone);
509 });
510
511 view
512}
513
514pub(crate) struct LlmProviderForm {
515 kind: CompatibleProviderKind,
516 provider_name: Entity<Editor>,
517 api_url: Entity<Editor>,
518 api_key: Entity<Editor>,
519 models: Vec<ModelInput>,
520 error: Option<SharedString>,
521}
522
523impl LlmProviderForm {
524 fn new(
525 kind: CompatibleProviderKind,
526 window: &mut Window,
527 cx: &mut Context<SettingsWindow>,
528 ) -> Self {
529 Self {
530 kind,
531 provider_name: new_input(kind.label(), None, false, window, cx),
532 api_url: new_input(kind.default_api_url(), None, false, window, cx),
533 api_key: new_input(
534 "000000000000000000000000000000000000000000000000",
535 None,
536 true,
537 window,
538 cx,
539 ),
540 models: vec![ModelInput::new(0, window, cx)],
541 error: None,
542 }
543 }
544}
545
546struct ModelInput {
547 name: Entity<Editor>,
548 max_completion_tokens: Entity<Editor>,
549 max_output_tokens: Entity<Editor>,
550 max_tokens: Entity<Editor>,
551 reasoning_effort: OpenAiReasoningEffort,
552 supports_tools: ToggleState,
553 supports_images: ToggleState,
554 supports_parallel_tool_calls: ToggleState,
555 supports_prompt_cache_key: ToggleState,
556 supports_chat_completions: ToggleState,
557 supports_thinking: ToggleState,
558 interleaved_reasoning: ToggleState,
559 max_tokens_parameter: ToggleState,
560}
561
562impl ModelInput {
563 fn new(_index: usize, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Self {
564 let OpenAiCompatibleModelCapabilities {
565 tools,
566 images,
567 parallel_tool_calls,
568 prompt_cache_key,
569 chat_completions,
570 interleaved_reasoning,
571 max_tokens_parameter,
572 } = OpenAiCompatibleModelCapabilities::default();
573
574 Self {
575 name: new_input(
576 "e.g. gpt-5, claude-opus-4, gemini-2.5-pro",
577 None,
578 false,
579 window,
580 cx,
581 ),
582 max_completion_tokens: new_input("200000", Some("200000"), false, window, cx),
583 max_output_tokens: new_input("Max Output Tokens", Some("32000"), false, window, cx),
584 max_tokens: new_input("Max Tokens", Some("200000"), false, window, cx),
585 reasoning_effort: OpenAiReasoningEffort::Medium,
586 supports_tools: tools.into(),
587 supports_images: images.into(),
588 supports_parallel_tool_calls: parallel_tool_calls.into(),
589 supports_prompt_cache_key: prompt_cache_key.into(),
590 supports_chat_completions: chat_completions.into(),
591 supports_thinking: ToggleState::Unselected,
592 interleaved_reasoning: interleaved_reasoning.into(),
593 max_tokens_parameter: max_tokens_parameter.into(),
594 }
595 }
596}
597
598fn new_input(
599 placeholder: &str,
600 initial: Option<&str>,
601 masked: bool,
602 window: &mut Window,
603 cx: &mut Context<SettingsWindow>,
604) -> Entity<Editor> {
605 let placeholder = placeholder.to_string();
606 let initial = initial.map(str::to_string);
607 cx.new(|cx| {
608 let mut editor = Editor::single_line(window, cx);
609 editor.set_placeholder_text(placeholder.as_str(), window, cx);
610 editor.set_masked(masked, cx);
611 if let Some(text) = initial {
612 editor.set_text(text, window, cx);
613 }
614 editor
615 })
616}
617
618fn open_llm_provider_form(
619 settings_window: &mut SettingsWindow,
620 kind: CompatibleProviderKind,
621 window: &mut Window,
622 cx: &mut Context<SettingsWindow>,
623) {
624 settings_window.llm_provider_form = Some(LlmProviderForm::new(kind, window, cx));
625 settings_window.push_dynamic_sub_page(
626 format!("Add {}-Compatible Provider", kind.label()),
627 "Agent Configuration",
628 Some("llm_providers"),
629 true,
630 render_llm_provider_form_page,
631 window,
632 cx,
633 );
634}
635
636fn render_llm_provider_form_page(
637 settings_window: &SettingsWindow,
638 scroll_handle: &ScrollHandle,
639 window: &mut Window,
640 cx: &mut Context<SettingsWindow>,
641) -> AnyElement {
642 let Some(form) = settings_window.llm_provider_form.as_ref() else {
643 return div().into_any_element();
644 };
645
646 v_flex()
647 .size_full()
648 .child(
649 v_flex()
650 .id("llm-provider-form-page")
651 .track_scroll(scroll_handle)
652 .pt_2p5()
653 .px_8()
654 .pb_16()
655 .gap_4()
656 .overflow_y_scroll()
657 .child(Label::new(match form.kind {
658 CompatibleProviderKind::OpenAi => {
659 "This provider will use an OpenAI-compatible API."
660 }
661 CompatibleProviderKind::Anthropic => {
662 "This provider will use an Anthropic Messages-compatible API."
663 }
664 }))
665 .child(Divider::horizontal().flex_shrink_0())
666 .child(render_form_field(
667 "Provider Name",
668 "A unique name used to identify this provider.",
669 &form.provider_name,
670 cx,
671 ))
672 .child(render_form_field(
673 "API URL",
674 "The base URL for the compatible API.",
675 &form.api_url,
676 cx,
677 ))
678 .child(render_form_field(
679 "API Key",
680 "Stored in the system keychain, not in settings.json.",
681 &form.api_key,
682 cx,
683 ))
684 .child(render_models_section(form, window, cx)),
685 )
686 .child(
687 v_flex()
688 .px_8()
689 .py_2p5()
690 .gap_1()
691 .border_t_1()
692 .border_color(cx.theme().colors().border_variant)
693 .when_some(form.error.clone(), |this, error| {
694 this.child(render_form_error(error))
695 })
696 .child(render_form_actions(cx)),
697 )
698 .into_any_element()
699}
700
701fn render_form_field(
702 title: &'static str,
703 description: &'static str,
704 editor: &Entity<Editor>,
705 cx: &mut Context<SettingsWindow>,
706) -> AnyElement {
707 let colors = cx.theme().colors();
708 let focus_handle = editor.focus_handle(cx).tab_index(0).tab_stop(true);
709 v_flex()
710 .w_full()
711 .gap_1p5()
712 .child(
713 v_flex()
714 .gap_0p5()
715 .child(
716 h_flex().gap_0p5().child(Label::new(title)).child(
717 Label::new("*")
718 .size(LabelSize::Small)
719 .color(Color::Error)
720 .mb_2(),
721 ),
722 )
723 .child(
724 Label::new(description)
725 .size(LabelSize::Small)
726 .color(Color::Muted),
727 ),
728 )
729 .child(
730 h_flex()
731 .w_full()
732 .min_w_64()
733 .h_8()
734 .px_2()
735 .rounded_md()
736 .border_1()
737 .border_color(colors.border)
738 .bg(colors.editor_background)
739 .track_focus(&focus_handle)
740 .focus(|style| style.border_color(colors.border_focused))
741 .child(editor.clone()),
742 )
743 .into_any_element()
744}
745
746fn render_models_section(
747 form: &LlmProviderForm,
748 window: &mut Window,
749 cx: &mut Context<SettingsWindow>,
750) -> impl IntoElement {
751 v_flex()
752 .mt_1()
753 .gap_2()
754 .child(
755 h_flex()
756 .justify_between()
757 .child(Label::new("Models"))
758 .child(
759 Button::new("add-model", "Add Model")
760 .start_icon(
761 Icon::new(IconName::Plus)
762 .size(IconSize::XSmall)
763 .color(Color::Muted),
764 )
765 .label_size(LabelSize::Small)
766 .on_click(cx.listener(|this, _, window, cx| {
767 if let Some(form) = this.llm_provider_form.as_mut() {
768 let index = form.models.len();
769 form.models.push(ModelInput::new(index, window, cx));
770 }
771 cx.notify();
772 })),
773 ),
774 )
775 .children(form.models.iter().enumerate().map(|(index, model)| {
776 render_model(form.kind, model, index, form.models.len(), window, cx)
777 }))
778}
779
780fn render_model(
781 kind: CompatibleProviderKind,
782 model: &ModelInput,
783 index: usize,
784 model_count: usize,
785 window: &mut Window,
786 cx: &mut Context<SettingsWindow>,
787) -> AnyElement {
788 v_flex()
789 .p_2()
790 .gap_2()
791 .rounded_sm()
792 .border_1()
793 .border_dashed()
794 .border_color(cx.theme().colors().border.opacity(0.6))
795 .bg(cx.theme().colors().element_active.opacity(0.15))
796 .child(render_form_field(
797 "Model Name",
798 "The model's name in the provider's API.",
799 &model.name,
800 cx,
801 ))
802 .when(matches!(kind, CompatibleProviderKind::OpenAi), |this| {
803 this.child(render_form_field(
804 "Max Completion Tokens",
805 "Maximum completion tokens for OpenAI-compatible requests.",
806 &model.max_completion_tokens,
807 cx,
808 ))
809 })
810 .child(render_form_field(
811 "Max Output Tokens",
812 "The maximum number of tokens the model can output.",
813 &model.max_output_tokens,
814 cx,
815 ))
816 .child(render_form_field(
817 "Max Tokens",
818 "The model context window size.",
819 &model.max_tokens,
820 cx,
821 ))
822 .child(render_model_capabilities(kind, model, index, window, cx))
823 .when(model_count > 1, |this| {
824 this.child(
825 Button::new(("remove-model", index), "Remove Model")
826 .start_icon(
827 Icon::new(IconName::Trash)
828 .size(IconSize::XSmall)
829 .color(Color::Muted),
830 )
831 .label_size(LabelSize::Small)
832 .style(ButtonStyle::Outlined)
833 .full_width()
834 .on_click(cx.listener(move |this, _, _window, cx| {
835 if let Some(form) = this.llm_provider_form.as_mut()
836 && index < form.models.len()
837 {
838 form.models.remove(index);
839 }
840 cx.notify();
841 })),
842 )
843 })
844 .into_any_element()
845}
846
847fn render_model_capabilities(
848 kind: CompatibleProviderKind,
849 model: &ModelInput,
850 index: usize,
851 window: &mut Window,
852 cx: &mut Context<SettingsWindow>,
853) -> impl IntoElement {
854 v_flex()
855 .gap_1()
856 .child(render_capability_checkbox(
857 "supports-tools",
858 index,
859 "Supports tools",
860 model.supports_tools,
861 |model, state| model.supports_tools = state,
862 cx,
863 ))
864 .child(render_capability_checkbox(
865 "supports-images",
866 index,
867 "Supports images",
868 model.supports_images,
869 |model, state| model.supports_images = state,
870 cx,
871 ))
872 .when(matches!(kind, CompatibleProviderKind::OpenAi), |this| {
873 this.child(render_capability_checkbox(
874 "supports-parallel-tool-calls",
875 index,
876 "Supports parallel_tool_calls",
877 model.supports_parallel_tool_calls,
878 |model, state| model.supports_parallel_tool_calls = state,
879 cx,
880 ))
881 .child(render_capability_checkbox(
882 "supports-prompt-cache-key",
883 index,
884 "Supports prompt_cache_key",
885 model.supports_prompt_cache_key,
886 |model, state| model.supports_prompt_cache_key = state,
887 cx,
888 ))
889 .child(render_capability_checkbox(
890 "supports-chat-completions",
891 index,
892 "Supports /chat/completions",
893 model.supports_chat_completions,
894 |model, state| model.supports_chat_completions = state,
895 cx,
896 ))
897 .when(model.supports_chat_completions.selected(), |this| {
898 this.child(render_capability_checkbox(
899 "max-tokens-parameter",
900 index,
901 "Uses max_tokens for output limit",
902 model.max_tokens_parameter,
903 |model, state| model.max_tokens_parameter = state,
904 cx,
905 ))
906 })
907 .child(render_capability_checkbox(
908 "supports-thinking",
909 index,
910 "Supports thinking",
911 model.supports_thinking,
912 |model, state| model.supports_thinking = state,
913 cx,
914 ))
915 .when(model.supports_thinking.selected(), |this| {
916 this.child(render_reasoning_effort_selector(
917 model.reasoning_effort,
918 index,
919 window,
920 cx,
921 ))
922 .when(model.supports_chat_completions.selected(), |this| {
923 this.child(render_capability_checkbox(
924 "interleaved-reasoning",
925 index,
926 "Preserves thinking in chat history",
927 model.interleaved_reasoning,
928 |model, state| model.interleaved_reasoning = state,
929 cx,
930 ))
931 })
932 })
933 })
934}
935
936fn render_capability_checkbox(
937 id: &'static str,
938 index: usize,
939 label: &'static str,
940 state: ToggleState,
941 update: fn(&mut ModelInput, ToggleState),
942 cx: &mut Context<SettingsWindow>,
943) -> impl IntoElement {
944 Checkbox::new((id, index), state)
945 .label(label)
946 .on_click(cx.listener(move |this, checked, _window, cx| {
947 if let Some(form) = this.llm_provider_form.as_mut()
948 && let Some(model) = form.models.get_mut(index)
949 {
950 update(model, *checked);
951 }
952 cx.notify();
953 }))
954}
955
956fn render_reasoning_effort_selector(
957 selected: OpenAiReasoningEffort,
958 index: usize,
959 window: &mut Window,
960 cx: &mut Context<SettingsWindow>,
961) -> impl IntoElement {
962 let settings_window = cx.weak_entity();
963 let menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| {
964 for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE {
965 let is_selected = effort == selected;
966 let settings_window = settings_window.clone();
967 menu.push_item(
968 ui::ContextMenuEntry::new(effort.label())
969 .toggleable(IconPosition::End, is_selected)
970 .handler(move |_window, cx| {
971 settings_window
972 .update(cx, |this, cx| {
973 if let Some(form) = this.llm_provider_form.as_mut()
974 && let Some(model) = form.models.get_mut(index)
975 {
976 model.reasoning_effort = effort;
977 }
978 cx.notify();
979 })
980 .ok();
981 }),
982 );
983 }
984 menu
985 });
986
987 v_flex()
988 .gap_1()
989 .child(Label::new("Default reasoning effort").size(LabelSize::Small))
990 .child(
991 DropdownMenu::new(
992 ElementId::Name(format!("reasoning-effort-selector-{index}").into()),
993 selected.label(),
994 menu,
995 )
996 .style(DropdownStyle::Outlined)
997 .trigger_size(ButtonSize::Compact)
998 .full_width(true)
999 .aria_label("Default reasoning effort"),
1000 )
1001}
1002
1003fn render_form_error(error: SharedString) -> impl IntoElement {
1004 h_flex()
1005 .w_full()
1006 .gap_2()
1007 .child(
1008 Icon::new(IconName::XCircle)
1009 .size(IconSize::Small)
1010 .color(Color::Error),
1011 )
1012 .child(Label::new(error).size(LabelSize::Small).color(Color::Error))
1013}
1014
1015fn render_form_actions(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1016 h_flex()
1017 .w_full()
1018 .gap_1()
1019 .justify_end()
1020 .child(
1021 Button::new("llm-provider-form-cancel", "Cancel").on_click(cx.listener(
1022 |this, _, window, cx| {
1023 this.llm_provider_form = None;
1024 this.pop_sub_page(window, cx);
1025 },
1026 )),
1027 )
1028 .child(
1029 Button::new("llm-provider-form-save", "Save Provider")
1030 .style(ButtonStyle::Filled)
1031 .on_click(cx.listener(|this, _, window, cx| {
1032 save_llm_provider_form(this, window, cx);
1033 })),
1034 )
1035}
1036
1037struct LlmProviderFormValues {
1038 kind: CompatibleProviderKind,
1039 provider_name: String,
1040 api_url: String,
1041 api_key: String,
1042 models: Vec<ModelValues>,
1043}
1044
1045struct ModelValues {
1046 name: String,
1047 max_completion_tokens: String,
1048 max_output_tokens: String,
1049 max_tokens: String,
1050 reasoning_effort: OpenAiReasoningEffort,
1051 supports_tools: bool,
1052 supports_images: bool,
1053 supports_parallel_tool_calls: bool,
1054 supports_prompt_cache_key: bool,
1055 supports_chat_completions: bool,
1056 supports_thinking: bool,
1057 interleaved_reasoning: bool,
1058 max_tokens_parameter: bool,
1059}
1060
1061enum ParsedModels {
1062 OpenAi(Vec<OpenAiCompatibleAvailableModel>),
1063 Anthropic(Vec<AnthropicCompatibleAvailableModel>),
1064}
1065
1066fn save_llm_provider_form(
1067 settings_window: &mut SettingsWindow,
1068 window: &mut Window,
1069 cx: &mut Context<SettingsWindow>,
1070) {
1071 let values = {
1072 let Some(form) = settings_window.llm_provider_form.as_ref() else {
1073 return;
1074 };
1075 LlmProviderFormValues {
1076 kind: form.kind,
1077 provider_name: form.provider_name.read(cx).text(cx),
1078 api_url: form.api_url.read(cx).text(cx),
1079 api_key: form.api_key.read(cx).text(cx),
1080 models: form
1081 .models
1082 .iter()
1083 .map(|model| ModelValues {
1084 name: model.name.read(cx).text(cx),
1085 max_completion_tokens: model.max_completion_tokens.read(cx).text(cx),
1086 max_output_tokens: model.max_output_tokens.read(cx).text(cx),
1087 max_tokens: model.max_tokens.read(cx).text(cx),
1088 reasoning_effort: model.reasoning_effort,
1089 supports_tools: model.supports_tools.selected(),
1090 supports_images: model.supports_images.selected(),
1091 supports_parallel_tool_calls: model.supports_parallel_tool_calls.selected(),
1092 supports_prompt_cache_key: model.supports_prompt_cache_key.selected(),
1093 supports_chat_completions: model.supports_chat_completions.selected(),
1094 supports_thinking: model.supports_thinking.selected(),
1095 interleaved_reasoning: model.interleaved_reasoning.selected(),
1096 max_tokens_parameter: model.max_tokens_parameter.selected(),
1097 })
1098 .collect(),
1099 }
1100 };
1101
1102 let (provider_name, api_url, api_key, models) = match validate_llm_provider_form(&values, cx) {
1103 Ok(value) => value,
1104 Err(error) => {
1105 if let Some(form) = settings_window.llm_provider_form.as_mut() {
1106 form.error = Some(error);
1107 }
1108 cx.notify();
1109 return;
1110 }
1111 };
1112
1113 let fs = <dyn fs::Fs>::global(cx);
1114 cx.spawn_in(window, async move |this, cx| {
1115 let result = async {
1116 let provider_id = LanguageModelProviderId(provider_name.clone().into());
1117 let settings_update = cx.update(|_window, cx| {
1118 settings::update_settings_file_with_completion(fs, cx, move |settings, _cx| {
1119 let language_models = settings.language_models.get_or_insert_default();
1120 match models {
1121 ParsedModels::OpenAi(available_models) => {
1122 language_models
1123 .openai_compatible
1124 .get_or_insert_default()
1125 .insert(
1126 Arc::from(provider_name.as_str()),
1127 OpenAiCompatibleSettingsContent {
1128 api_url: api_url.clone(),
1129 available_models,
1130 custom_headers: None,
1131 },
1132 );
1133 }
1134 ParsedModels::Anthropic(available_models) => {
1135 language_models
1136 .anthropic_compatible
1137 .get_or_insert_default()
1138 .insert(
1139 Arc::from(provider_name.as_str()),
1140 AnthropicCompatibleSettingsContent {
1141 api_url: api_url.clone(),
1142 available_models,
1143 custom_headers: None,
1144 },
1145 );
1146 }
1147 }
1148 })
1149 })?;
1150
1151 settings_update
1152 .await
1153 .map_err(|_| anyhow::anyhow!("Settings update was canceled"))??;
1154
1155 let set_api_key = cx.update(|_window, cx| {
1156 let provider = LanguageModelRegistry::read_global(cx)
1157 .provider(&provider_id)
1158 .ok_or_else(|| anyhow::anyhow!("Provider was not registered"))?;
1159 anyhow::Ok(provider.set_api_key(Some(api_key), cx))
1160 })??;
1161 set_api_key.await?;
1162
1163 cx.update(|window, cx| {
1164 this.update(cx, |this, cx| {
1165 this.provider_configuration_views.remove(&provider_id);
1166 this.llm_provider_form = None;
1167 this.pop_sub_page(window, cx);
1168 })
1169 })??;
1170
1171 anyhow::Ok(())
1172 }
1173 .await;
1174
1175 if let Err(error) = result {
1176 this.update(cx, |this, cx| {
1177 if let Some(form) = this.llm_provider_form.as_mut() {
1178 form.error = Some(error.to_string().into());
1179 }
1180 cx.notify();
1181 })?;
1182 }
1183
1184 anyhow::Ok(())
1185 })
1186 .detach_and_log_err(cx);
1187}
1188
1189fn validate_llm_provider_form(
1190 values: &LlmProviderFormValues,
1191 cx: &App,
1192) -> Result<(String, String, String, ParsedModels), SharedString> {
1193 let provider_name = values.provider_name.clone();
1194 if provider_name.is_empty() {
1195 return Err("Provider Name cannot be empty".into());
1196 }
1197
1198 if LanguageModelRegistry::read_global(cx)
1199 .providers()
1200 .iter()
1201 .any(|provider| {
1202 provider.id().0.as_ref() == provider_name.as_str()
1203 || provider.name().0.as_ref() == provider_name.as_str()
1204 })
1205 {
1206 return Err("Provider Name is already taken by another provider".into());
1207 }
1208
1209 let api_url = values.api_url.clone();
1210 if api_url.is_empty() {
1211 return Err("API URL cannot be empty".into());
1212 }
1213
1214 let api_key = values.api_key.clone();
1215 if api_key.is_empty() {
1216 return Err("API Key cannot be empty".into());
1217 }
1218
1219 let models = match values.kind {
1220 CompatibleProviderKind::OpenAi => ParsedModels::OpenAi(
1221 values
1222 .models
1223 .iter()
1224 .map(parse_open_ai_model)
1225 .collect::<Result<Vec<_>, _>>()?,
1226 ),
1227 CompatibleProviderKind::Anthropic => ParsedModels::Anthropic(
1228 values
1229 .models
1230 .iter()
1231 .map(parse_anthropic_model)
1232 .collect::<Result<Vec<_>, _>>()?,
1233 ),
1234 };
1235
1236 let mut model_names = HashSet::new();
1237 let model_names_are_unique = match &models {
1238 ParsedModels::OpenAi(models) => models
1239 .iter()
1240 .all(|model| model_names.insert(model.name.clone())),
1241 ParsedModels::Anthropic(models) => models
1242 .iter()
1243 .all(|model| model_names.insert(model.name.clone())),
1244 };
1245 if !model_names_are_unique {
1246 return Err("Model Names must be unique".into());
1247 }
1248
1249 Ok((provider_name, api_url, api_key, models))
1250}
1251
1252fn parse_model_name(model: &ModelValues) -> Result<String, SharedString> {
1253 if model.name.is_empty() {
1254 return Err("Model Name cannot be empty".into());
1255 }
1256 Ok(model.name.clone())
1257}
1258
1259fn parse_open_ai_model(
1260 model: &ModelValues,
1261) -> Result<OpenAiCompatibleAvailableModel, SharedString> {
1262 Ok(OpenAiCompatibleAvailableModel {
1263 name: parse_model_name(model)?,
1264 display_name: None,
1265 max_completion_tokens: Some(parse_u64_field(
1266 &model.max_completion_tokens,
1267 "Max Completion Tokens",
1268 )?),
1269 max_output_tokens: Some(parse_u64_field(
1270 &model.max_output_tokens,
1271 "Max Output Tokens",
1272 )?),
1273 max_tokens: parse_u64_field(&model.max_tokens, "Max Tokens")?,
1274 reasoning_effort: model.supports_thinking.then_some(model.reasoning_effort),
1275 capabilities: OpenAiCompatibleModelCapabilities {
1276 tools: model.supports_tools,
1277 images: model.supports_images,
1278 parallel_tool_calls: model.supports_parallel_tool_calls,
1279 prompt_cache_key: model.supports_prompt_cache_key,
1280 chat_completions: model.supports_chat_completions,
1281 interleaved_reasoning: model.supports_thinking
1282 && model.supports_chat_completions
1283 && model.interleaved_reasoning,
1284 max_tokens_parameter: model.supports_chat_completions && model.max_tokens_parameter,
1285 },
1286 })
1287}
1288
1289fn parse_anthropic_model(
1290 model: &ModelValues,
1291) -> Result<AnthropicCompatibleAvailableModel, SharedString> {
1292 Ok(AnthropicCompatibleAvailableModel {
1293 name: parse_model_name(model)?,
1294 display_name: None,
1295 max_tokens: parse_u64_field(&model.max_tokens, "Max Tokens")?,
1296 tool_override: None,
1297 max_output_tokens: Some(parse_u64_field(
1298 &model.max_output_tokens,
1299 "Max Output Tokens",
1300 )?),
1301 default_temperature: None,
1302 extra_beta_headers: Vec::new(),
1303 mode: None,
1304 capabilities: AnthropicCompatibleModelCapabilities {
1305 tools: model.supports_tools,
1306 images: model.supports_images,
1307 prompt_caching: false,
1308 },
1309 })
1310}
1311
1312fn parse_u64_field(value: &str, name: &str) -> Result<u64, SharedString> {
1313 value
1314 .parse::<u64>()
1315 .map_err(|_| format!("{name} must be a number").into())
1316}
1317