Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:48:54.629Z 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

llm_providers_page.rs

1317 lines · 45.0 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.