Skip to repository content

tenant.openagents/omega

No repository description is available.

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

ollama.rs

1254 lines · 48.9 KB · rust
1use anyhow::{Result, anyhow};
2use collections::HashMap;
3use credentials_provider::CredentialsProvider;
4use fs::Fs;
5use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
6use futures::{Stream, TryFutureExt, stream};
7use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt};
8use http_client::{CustomHeaders, HttpClient};
9use language_model::{
10    ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, InlineDescription,
11    LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
12    LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
13    LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool,
14    LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent,
15    ProviderSettingsView, RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage,
16    env_var,
17};
18use menu;
19use ollama::{
20    ChatMessage, ChatOptions, ChatRequest, ChatResponseDelta, OLLAMA_API_URL, OllamaFunctionCall,
21    OllamaFunctionTool, OllamaToolCall, get_models, show_model, stream_chat_completion,
22};
23pub use settings::OllamaAvailableModel as AvailableModel;
24use settings::{Settings, SettingsStore, update_settings_file};
25use std::pin::Pin;
26use std::sync::Arc;
27use std::sync::LazyLock;
28use ui::{
29    ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*,
30};
31use ui_input::InputField;
32
33use crate::AllLanguageModelSettings;
34
35const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download";
36const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library";
37const OLLAMA_SITE: &str = "https://ollama.com/";
38
39const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("ollama");
40const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Ollama");
41
42const API_KEY_ENV_VAR_NAME: &str = "OLLAMA_API_KEY";
43static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
44
45#[derive(Default, Debug, Clone, PartialEq)]
46pub struct OllamaSettings {
47    pub api_url: String,
48    pub auto_discover: bool,
49    pub available_models: Vec<AvailableModel>,
50    pub context_window: Option<u64>,
51    pub custom_headers: CustomHeaders,
52}
53
54pub struct OllamaLanguageModelProvider {
55    http_client: Arc<dyn HttpClient>,
56    state: Entity<State>,
57}
58
59pub struct State {
60    api_key_state: ApiKeyState,
61    credentials_provider: Arc<dyn CredentialsProvider>,
62    http_client: Arc<dyn HttpClient>,
63    fetched_models: Vec<ollama::Model>,
64    fetch_model_task: Option<Task<Result<()>>>,
65}
66
67impl State {
68    fn is_authenticated(&self) -> bool {
69        !self.fetched_models.is_empty()
70    }
71
72    fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
73        let credentials_provider = self.credentials_provider.clone();
74        let api_url = OllamaLanguageModelProvider::api_url(cx);
75        let task = self.api_key_state.store(
76            api_url,
77            api_key,
78            |this| &mut this.api_key_state,
79            credentials_provider,
80            cx,
81        );
82
83        self.fetched_models.clear();
84        cx.spawn(async move |this, cx| {
85            let result = task.await;
86            this.update(cx, |this, cx| this.restart_fetch_models_task(cx))
87                .ok();
88            result
89        })
90    }
91
92    fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
93        let credentials_provider = self.credentials_provider.clone();
94        let api_url = OllamaLanguageModelProvider::api_url(cx);
95        let task = self.api_key_state.load_if_needed(
96            api_url,
97            |this| &mut this.api_key_state,
98            credentials_provider,
99            cx,
100        );
101
102        // Always try to fetch models - if no API key is needed (local Ollama), it will work
103        // If API key is needed and provided, it will work
104        // If API key is needed and not provided, it will fail gracefully
105        cx.spawn(async move |this, cx| {
106            let result = task.await;
107            this.update(cx, |this, cx| this.restart_fetch_models_task(cx))
108                .ok();
109            result
110        })
111    }
112
113    fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
114        let http_client = Arc::clone(&self.http_client);
115        let settings = OllamaLanguageModelProvider::settings(cx);
116        let api_url = OllamaLanguageModelProvider::api_url(cx);
117        let api_key = self.api_key_state.key(&api_url);
118        let extra_headers = settings.custom_headers.clone();
119
120        // As a proxy for the server being "authenticated", we'll check if its up by fetching the models
121        cx.spawn(async move |this, cx| {
122            let models = get_models(
123                http_client.as_ref(),
124                &api_url,
125                api_key.as_deref(),
126                &extra_headers,
127            )
128            .await?;
129
130            let tasks = models
131                .into_iter()
132                // Since there is no metadata from the Ollama API
133                // indicating which models are embedding models,
134                // simply filter out models with "-embed" in their name
135                .filter(|model| !model.name.contains("-embed"))
136                .map(|model| {
137                    let http_client = Arc::clone(&http_client);
138                    let api_url = api_url.clone();
139                    let api_key = api_key.clone();
140                    let extra_headers = extra_headers.clone();
141                    async move {
142                        let name = model.name.as_str();
143
144                        show_model(
145                            http_client.as_ref(),
146                            &api_url,
147                            api_key.as_deref(),
148                            name,
149                            &extra_headers,
150                        )
151                        .await
152                        .map_or_else(
153                            |error| {
154                                ollama::Model::new_disabled(
155                                    name,
156                                    format!("Failed to fetch model from API: {error}",),
157                                )
158                            },
159                            |model| {
160                                ollama::Model::new(
161                                    name,
162                                    model.context_length,
163                                    Some(model.supports_tools()),
164                                    Some(model.supports_vision()),
165                                    Some(model.supports_thinking()),
166                                )
167                            },
168                        )
169                    }
170                });
171
172            // Rate-limit capability fetches
173            // since there is an arbitrary number of models available
174            let mut ollama_models: Vec<_> = futures::stream::iter(tasks)
175                .buffer_unordered(5)
176                .collect()
177                .await;
178
179            ollama_models.sort_by(|a, b| a.name.cmp(&b.name));
180
181            this.update(cx, |this, cx| {
182                this.fetched_models = ollama_models;
183                cx.notify();
184            })
185        })
186    }
187
188    fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
189        let task = self.fetch_models(cx);
190        self.fetch_model_task.replace(task);
191    }
192}
193
194impl OllamaLanguageModelProvider {
195    pub fn new(
196        http_client: Arc<dyn HttpClient>,
197        credentials_provider: Arc<dyn CredentialsProvider>,
198        cx: &mut App,
199    ) -> Self {
200        let this = Self {
201            http_client: http_client.clone(),
202            state: cx.new(|cx| {
203                cx.observe_global::<SettingsStore>({
204                    let mut last_settings = OllamaLanguageModelProvider::settings(cx).clone();
205                    move |this: &mut State, cx| {
206                        let current_settings = OllamaLanguageModelProvider::settings(cx);
207                        let settings_changed = current_settings != &last_settings;
208                        if settings_changed {
209                            let url_changed = last_settings.api_url != current_settings.api_url;
210                            last_settings = current_settings.clone();
211                            if url_changed {
212                                let credentials_provider = this.credentials_provider.clone();
213                                let api_url = Self::api_url(cx);
214                                this.api_key_state.handle_url_change(
215                                    api_url,
216                                    |this| &mut this.api_key_state,
217                                    credentials_provider,
218                                    cx,
219                                );
220                                this.fetched_models.clear();
221                                this.authenticate(cx).detach();
222                            }
223                            cx.notify();
224                        }
225                    }
226                })
227                .detach();
228
229                State {
230                    http_client,
231                    fetched_models: Default::default(),
232                    fetch_model_task: None,
233                    api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
234                    credentials_provider,
235                }
236            }),
237        };
238        this
239    }
240
241    fn settings(cx: &App) -> &OllamaSettings {
242        &AllLanguageModelSettings::get_global(cx).ollama
243    }
244
245    fn api_url(cx: &App) -> SharedString {
246        let api_url = &Self::settings(cx).api_url;
247        if api_url.is_empty() {
248            OLLAMA_API_URL.into()
249        } else {
250            SharedString::new(api_url.as_str())
251        }
252    }
253
254    fn has_custom_url(cx: &App) -> bool {
255        Self::settings(cx).api_url != OLLAMA_API_URL
256    }
257}
258
259impl LanguageModelProviderState for OllamaLanguageModelProvider {
260    type ObservableEntity = State;
261
262    fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
263        Some(self.state.clone())
264    }
265}
266
267impl LanguageModelProvider for OllamaLanguageModelProvider {
268    fn id(&self) -> LanguageModelProviderId {
269        PROVIDER_ID
270    }
271
272    fn name(&self) -> LanguageModelProviderName {
273        PROVIDER_NAME
274    }
275
276    fn icon(&self) -> IconOrSvg {
277        IconOrSvg::Icon(IconName::AiOllama)
278    }
279
280    fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
281        // We shouldn't try to select default model, because it might lead to a load call for an unloaded model.
282        // In a constrained environment where user might not have enough resources it'll be a bad UX to select something
283        // to load by default.
284        None
285    }
286
287    fn default_fast_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
288        // See explanation for default_model.
289        None
290    }
291
292    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
293        let mut models: HashMap<String, ollama::Model> = HashMap::default();
294        let settings = OllamaLanguageModelProvider::settings(cx);
295
296        if settings.auto_discover {
297            // Add models from the Ollama API
298            for model in self.state.read(cx).fetched_models.iter() {
299                let mut model = model.clone();
300                if let Some(context_window) = settings.context_window {
301                    model.max_tokens = context_window;
302                }
303                models.insert(model.name.clone(), model);
304            }
305        }
306
307        // Override with available models from settings
308        merge_settings_into_models(
309            &mut models,
310            &settings.available_models,
311            settings.context_window,
312        );
313
314        let mut models = models
315            .into_values()
316            .map(|model| {
317                Arc::new(OllamaLanguageModel {
318                    id: LanguageModelId::from(model.name.clone()),
319                    disabled: model.disabled.as_ref().map(|d| DisabledReason::new(d)),
320                    model,
321                    http_client: self.http_client.clone(),
322                    request_limiter: RateLimiter::new(4),
323                    state: self.state.clone(),
324                }) as Arc<dyn LanguageModel>
325            })
326            .collect::<Vec<_>>();
327        models.sort_by_key(|model| model.name());
328        models
329    }
330
331    fn is_authenticated(&self, cx: &App) -> bool {
332        self.state.read(cx).is_authenticated()
333    }
334
335    fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
336        self.state.update(cx, |state, cx| state.authenticate(cx))
337    }
338
339    fn settings_view(&self, _cx: &mut App) -> Option<ProviderSettingsView> {
340        let state = self.state.clone();
341        Some(ProviderSettingsView::SubPage(
342            SubPageProviderSettings::new(move |window, cx| {
343                cx.new(|cx| ConfigurationView::new(state.clone(), window, cx))
344                    .into()
345            })
346            .description(InlineDescription::Text(
347                "Run local models on your machine with Ollama.".into(),
348            )),
349        ))
350    }
351}
352
353pub struct OllamaLanguageModel {
354    id: LanguageModelId,
355    model: ollama::Model,
356    http_client: Arc<dyn HttpClient>,
357    request_limiter: RateLimiter,
358    state: Entity<State>,
359    disabled: Option<DisabledReason>,
360}
361
362impl OllamaLanguageModel {
363    fn to_ollama_request(&self, request: LanguageModelRequest) -> Result<ChatRequest> {
364        if request.contains_custom_tool_input() {
365            anyhow::bail!("Ollama does not support custom tools");
366        }
367
368        let supports_vision = self.model.supports_vision.unwrap_or(false);
369
370        let mut messages = Vec::with_capacity(request.messages.len());
371
372        for mut msg in request.messages.into_iter() {
373            let images = if supports_vision {
374                msg.content
375                    .iter()
376                    .filter_map(|content| match content {
377                        MessageContent::Image(image) => Some(image.source.to_string()),
378                        _ => None,
379                    })
380                    .collect::<Vec<String>>()
381            } else {
382                vec![]
383            };
384
385            match msg.role {
386                Role::User => {
387                    for tool_result in msg
388                        .content
389                        .extract_if(.., |x| matches!(x, MessageContent::ToolResult(..)))
390                    {
391                        match tool_result {
392                            MessageContent::ToolResult(tool_result) => {
393                                messages.push(ChatMessage::Tool {
394                                    tool_name: tool_result.tool_name.to_string(),
395                                    content: tool_result.text_contents(),
396                                })
397                            }
398                            _ => unreachable!("Only tool result should be extracted"),
399                        }
400                    }
401                    if !msg.content.is_empty() {
402                        messages.push(ChatMessage::User {
403                            content: msg.string_contents(),
404                            images: if images.is_empty() {
405                                None
406                            } else {
407                                Some(images)
408                            },
409                        })
410                    }
411                }
412                Role::Assistant => {
413                    let mut text_content = String::new();
414                    let mut thinking = None;
415                    let mut tool_calls = Vec::new();
416                    for content in msg.content.into_iter() {
417                        match content {
418                            MessageContent::Text(text) => {
419                                text_content.push_str(&text);
420                            }
421                            MessageContent::Thinking { text, .. } if !text.is_empty() => {
422                                thinking = Some(text)
423                            }
424                            MessageContent::ToolUse(tool_use) => {
425                                tool_calls.push(OllamaToolCall {
426                                    id: tool_use.id.to_string(),
427                                    function: OllamaFunctionCall {
428                                        name: tool_use.name.to_string(),
429                                        arguments: match tool_use.input {
430                                            language_model::LanguageModelToolUseInput::Json(
431                                                input,
432                                            ) => input,
433                                            language_model::LanguageModelToolUseInput::Text(_) => {
434                                                return Err(anyhow::anyhow!(
435                                                    "Ollama does not support custom tool calls"
436                                                ));
437                                            }
438                                        },
439                                    },
440                                });
441                            }
442                            _ => (),
443                        }
444                    }
445                    messages.push(ChatMessage::Assistant {
446                        content: text_content,
447                        tool_calls: Some(tool_calls),
448                        images: if images.is_empty() {
449                            None
450                        } else {
451                            Some(images)
452                        },
453                        thinking,
454                    })
455                }
456                Role::System => messages.push(ChatMessage::System {
457                    content: msg.string_contents(),
458                }),
459            }
460        }
461        Ok(ChatRequest {
462            model: self.model.name.clone(),
463            messages,
464            keep_alive: self.model.keep_alive.clone().unwrap_or_default(),
465            stream: true,
466            options: Some(ChatOptions {
467                num_ctx: Some(self.model.max_tokens),
468                // Only send stop tokens if explicitly provided. When empty/None,
469                // Ollama will use the model's default stop tokens from its Modelfile.
470                // Sending an empty array would override and disable the defaults.
471                stop: if request.stop.is_empty() {
472                    None
473                } else {
474                    Some(request.stop)
475                },
476                temperature: request.temperature.or(Some(1.0)),
477                ..Default::default()
478            }),
479            think: self
480                .model
481                .supports_thinking
482                .map(|supports_thinking| supports_thinking && request.thinking_allowed),
483            tools: if self.model.supports_tools.unwrap_or(false) {
484                request
485                    .tools
486                    .into_iter()
487                    .map(tool_into_ollama)
488                    .collect::<Result<_>>()?
489            } else {
490                vec![]
491            },
492        })
493    }
494}
495
496impl LanguageModel for OllamaLanguageModel {
497    fn id(&self) -> LanguageModelId {
498        self.id.clone()
499    }
500
501    fn name(&self) -> LanguageModelName {
502        LanguageModelName::from(self.model.display_name().to_string())
503    }
504
505    fn provider_id(&self) -> LanguageModelProviderId {
506        PROVIDER_ID
507    }
508
509    fn provider_name(&self) -> LanguageModelProviderName {
510        PROVIDER_NAME
511    }
512
513    fn supports_tools(&self) -> bool {
514        self.model.supports_tools.unwrap_or(false)
515    }
516
517    fn supports_images(&self) -> bool {
518        self.model.supports_vision.unwrap_or(false)
519    }
520
521    fn supports_thinking(&self) -> bool {
522        self.model.supports_thinking.unwrap_or(false)
523    }
524
525    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
526        match choice {
527            LanguageModelToolChoice::Auto => false,
528            LanguageModelToolChoice::Any => false,
529            LanguageModelToolChoice::None => false,
530        }
531    }
532
533    fn telemetry_id(&self) -> String {
534        format!("ollama/{}", self.model.id())
535    }
536
537    fn is_disabled(&self) -> Option<DisabledReason> {
538        self.disabled.clone()
539    }
540
541    fn max_token_count(&self) -> u64 {
542        self.model.max_token_count()
543    }
544
545    fn stream_completion(
546        &self,
547        request: LanguageModelRequest,
548        cx: &AsyncApp,
549    ) -> BoxFuture<
550        'static,
551        Result<
552            BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
553            LanguageModelCompletionError,
554        >,
555    > {
556        let request = match self.to_ollama_request(request) {
557            Ok(request) => request,
558            Err(error) => return async move { Err(error.into()) }.boxed(),
559        };
560
561        let http_client = self.http_client.clone();
562        let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| {
563            let api_url = OllamaLanguageModelProvider::api_url(cx);
564            let extra_headers = OllamaLanguageModelProvider::settings(cx)
565                .custom_headers
566                .clone();
567            (state.api_key_state.key(&api_url), api_url, extra_headers)
568        });
569
570        let future = self.request_limiter.stream(async move {
571            let stream = stream_chat_completion(
572                http_client.as_ref(),
573                &api_url,
574                api_key.as_deref(),
575                request,
576                &extra_headers,
577            )
578            .await?;
579            let stream = map_to_language_model_completion_events(stream);
580            Ok(stream)
581        });
582
583        future.map_ok(|f| f.boxed()).boxed()
584    }
585}
586
587fn map_to_language_model_completion_events(
588    stream: Pin<Box<dyn Stream<Item = anyhow::Result<ChatResponseDelta>> + Send>>,
589) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
590    struct State {
591        stream: Pin<Box<dyn Stream<Item = anyhow::Result<ChatResponseDelta>> + Send>>,
592        used_tools: bool,
593    }
594
595    // We need to create a ToolUse and Stop event from a single
596    // response from the original stream
597    let stream = stream::unfold(
598        State {
599            stream,
600            used_tools: false,
601        },
602        async move |mut state| {
603            let response = state.stream.next().await?;
604
605            let delta = match response {
606                Ok(delta) => delta,
607                Err(e) => {
608                    let event = Err(LanguageModelCompletionError::from(anyhow!(e)));
609                    return Some((vec![event], state));
610                }
611            };
612
613            let mut events = Vec::new();
614
615            match delta.message {
616                ChatMessage::User { content, images: _ } => {
617                    events.push(Ok(LanguageModelCompletionEvent::Text(content)));
618                }
619                ChatMessage::System { content } => {
620                    events.push(Ok(LanguageModelCompletionEvent::Text(content)));
621                }
622                ChatMessage::Tool { content, .. } => {
623                    events.push(Ok(LanguageModelCompletionEvent::Text(content)));
624                }
625                ChatMessage::Assistant {
626                    content,
627                    tool_calls,
628                    images: _,
629                    thinking,
630                } => {
631                    if let Some(text) = thinking {
632                        events.push(Ok(LanguageModelCompletionEvent::Thinking {
633                            text,
634                            signature: None,
635                        }));
636                    }
637
638                    if let Some(tool_call) = tool_calls.and_then(|v| v.into_iter().next()) {
639                        let OllamaToolCall { id, function } = tool_call;
640                        let event = LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
641                            id: LanguageModelToolUseId::from(id),
642                            name: Arc::from(function.name),
643                            raw_input: function.arguments.to_string(),
644                            input: language_model::LanguageModelToolUseInput::Json(
645                                function.arguments,
646                            ),
647                            is_input_complete: true,
648                            thought_signature: None,
649                        });
650                        events.push(Ok(event));
651                        state.used_tools = true;
652                    } else if !content.is_empty() {
653                        events.push(Ok(LanguageModelCompletionEvent::Text(content)));
654                    }
655                }
656            };
657
658            if delta.done {
659                events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
660                    input_tokens: delta.prompt_eval_count.unwrap_or(0),
661                    output_tokens: delta.eval_count.unwrap_or(0),
662                    cache_creation_input_tokens: 0,
663                    cache_read_input_tokens: 0,
664                })));
665                if state.used_tools {
666                    state.used_tools = false;
667                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
668                } else {
669                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
670                }
671            }
672
673            Some((events, state))
674        },
675    );
676
677    stream.flat_map(futures::stream::iter)
678}
679
680struct ConfigurationView {
681    api_key_editor: Entity<InputField>,
682    api_url_editor: Entity<InputField>,
683    context_window_editor: Entity<InputField>,
684    state: Entity<State>,
685}
686
687impl ConfigurationView {
688    pub fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
689        let api_key_editor = cx.new(|cx| InputField::new(window, cx, "63e02e...").label("API key"));
690
691        let api_url_editor = cx.new(|cx| {
692            let input = InputField::new(window, cx, OLLAMA_API_URL).label("API URL");
693            input.set_text(&OllamaLanguageModelProvider::api_url(cx), window, cx);
694            input
695        });
696
697        let context_window_editor = cx.new(|cx| {
698            let input = InputField::new(window, cx, "8192").label("Context Window");
699            if let Some(context_window) = OllamaLanguageModelProvider::settings(cx).context_window {
700                input.set_text(&context_window.to_string(), window, cx);
701            }
702            input
703        });
704
705        cx.observe(&state, |_, _, cx| {
706            cx.notify();
707        })
708        .detach();
709
710        Self {
711            api_key_editor,
712            api_url_editor,
713            context_window_editor,
714            state,
715        }
716    }
717
718    fn retry_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
719        let has_api_url = OllamaLanguageModelProvider::has_custom_url(cx);
720        let has_api_key = self
721            .state
722            .read_with(cx, |state, _| state.api_key_state.has_key());
723        if !has_api_url {
724            self.save_api_url(cx);
725        }
726        if !has_api_key {
727            self.save_api_key(&Default::default(), window, cx);
728        }
729
730        self.state.update(cx, |state, cx| {
731            state.restart_fetch_models_task(cx);
732        });
733    }
734
735    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
736        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
737        if api_key.is_empty() {
738            return;
739        }
740
741        // url changes can cause the editor to be displayed again
742        self.api_key_editor
743            .update(cx, |input, cx| input.set_text("", window, cx));
744
745        let state = self.state.clone();
746        cx.spawn_in(window, async move |_, cx| {
747            state
748                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
749                .await
750        })
751        .detach_and_log_err(cx);
752    }
753
754    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
755        self.api_key_editor
756            .update(cx, |input, cx| input.set_text("", window, cx));
757
758        let state = self.state.clone();
759        cx.spawn_in(window, async move |_, cx| {
760            state
761                .update(cx, |state, cx| state.set_api_key(None, cx))
762                .await
763        })
764        .detach_and_log_err(cx);
765
766        cx.notify();
767    }
768
769    fn save_api_url(&self, cx: &mut Context<Self>) {
770        let api_url = self.api_url_editor.read(cx).text(cx).trim().to_string();
771        let current_url = OllamaLanguageModelProvider::api_url(cx);
772        if !api_url.is_empty() && &api_url != &current_url {
773            let fs = <dyn Fs>::global(cx);
774            update_settings_file(fs, cx, move |settings, _| {
775                settings
776                    .language_models
777                    .get_or_insert_default()
778                    .ollama
779                    .get_or_insert_default()
780                    .api_url = Some(api_url);
781            });
782        }
783    }
784
785    fn reset_api_url(&mut self, window: &mut Window, cx: &mut Context<Self>) {
786        self.api_url_editor
787            .update(cx, |input, cx| input.set_text("", window, cx));
788        let fs = <dyn Fs>::global(cx);
789        update_settings_file(fs, cx, |settings, _cx| {
790            if let Some(settings) = settings
791                .language_models
792                .as_mut()
793                .and_then(|models| models.ollama.as_mut())
794            {
795                settings.api_url = Some(OLLAMA_API_URL.into());
796            }
797        });
798        cx.notify();
799    }
800
801    fn save_context_window(&mut self, cx: &mut Context<Self>) {
802        let context_window_str = self
803            .context_window_editor
804            .read(cx)
805            .text(cx)
806            .trim()
807            .to_string();
808        let current_context_window = OllamaLanguageModelProvider::settings(cx).context_window;
809
810        if let Ok(context_window) = context_window_str.parse::<u64>() {
811            if Some(context_window) != current_context_window {
812                let fs = <dyn Fs>::global(cx);
813                update_settings_file(fs, cx, move |settings, _| {
814                    settings
815                        .language_models
816                        .get_or_insert_default()
817                        .ollama
818                        .get_or_insert_default()
819                        .context_window = Some(context_window);
820                });
821            }
822        } else if context_window_str.is_empty() && current_context_window.is_some() {
823            let fs = <dyn Fs>::global(cx);
824            update_settings_file(fs, cx, move |settings, _| {
825                settings
826                    .language_models
827                    .get_or_insert_default()
828                    .ollama
829                    .get_or_insert_default()
830                    .context_window = None;
831            });
832        }
833    }
834
835    fn reset_context_window(&mut self, window: &mut Window, cx: &mut Context<Self>) {
836        self.context_window_editor
837            .update(cx, |input, cx| input.set_text("", window, cx));
838        let fs = <dyn Fs>::global(cx);
839        update_settings_file(fs, cx, |settings, _cx| {
840            if let Some(settings) = settings
841                .language_models
842                .as_mut()
843                .and_then(|models| models.ollama.as_mut())
844            {
845                settings.context_window = None;
846            }
847        });
848        cx.notify();
849    }
850
851    fn render_instructions(cx: &App) -> Div {
852        v_flex()
853            .gap_2()
854            .child(
855                Label::new(
856                    "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \
857                Can provide access to Llama, Mistral, Gemma, and hundreds of other models.",
858                )
859                .color(Color::Muted),
860            )
861            .child(Label::new("To use local Ollama:").color(Color::Muted))
862            .child(
863                List::new()
864                    .child(
865                        ListBulletItem::new("")
866                            .child(
867                                Label::new("Download and install Ollama from").color(Color::Muted),
868                            )
869                            .child(ButtonLink::new("ollama.com", "https://ollama.com/download")),
870                    )
871                    .child(
872                        ListBulletItem::new("")
873                            .child(
874                                Label::new("Start Ollama and download a model:")
875                                    .color(Color::Muted),
876                            )
877                            .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)),
878                    )
879                    .child(
880                        ListBulletItem::new("Click 'Connect' below to start using Ollama in Omega")
881                            .label_color(Color::Muted),
882                    ),
883            )
884            .child(
885                Label::new(
886                    "Alternatively, you can connect to an Ollama server by specifying its \
887                URL and API key (may not be required):",
888                )
889                .color(Color::Muted),
890            )
891    }
892
893    fn render_api_key_editor(&self, cx: &Context<Self>) -> impl IntoElement {
894        let state = self.state.read(cx);
895        let env_var_set = state.api_key_state.is_from_env_var();
896        let configured_card_label = if env_var_set {
897            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable.")
898        } else {
899            "API key configured".to_string()
900        };
901
902        let api_key_control = if !state.api_key_state.has_key() {
903            self.api_key_editor.clone().into_any_element()
904        } else {
905            ConfiguredApiCard::new("ollama-reset-key", configured_card_label)
906                .disabled(env_var_set)
907                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
908                .when(env_var_set, |this| {
909                    this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
910                })
911                .into_any_element()
912        };
913
914        v_flex()
915          .on_action(cx.listener(Self::save_api_key))
916          .child(api_key_control)
917          .gap_1p5()
918          .mb_2()
919          .child(
920              Label::new(
921                  format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Omega.")
922              )
923              .size(LabelSize::Small)
924              .color(Color::Muted),
925          )
926    }
927
928    fn render_context_window_editor(&self, cx: &Context<Self>) -> Div {
929        let settings = OllamaLanguageModelProvider::settings(cx);
930        let custom_context_window_set = settings.context_window.is_some();
931
932        if custom_context_window_set {
933            h_flex()
934                .p_1()
935                .justify_between()
936                .rounded_md()
937                .border_1()
938                .border_color(cx.theme().colors().border_variant)
939                .bg(cx.theme().colors().background.opacity(0.5))
940                .child(
941                    h_flex()
942                        .gap_1()
943                        .child(Icon::new(IconName::Check).color(Color::Success))
944                        .child(Label::new(format!(
945                            "Context Window: {}",
946                            settings.context_window.unwrap()
947                        ))),
948                )
949                .child(
950                    Button::new("reset-context-window", "Reset")
951                        .style(ButtonStyle::Outlined)
952                        .label_size(LabelSize::Small)
953                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
954                        .on_click(
955                            cx.listener(|this, _, window, cx| {
956                                this.reset_context_window(window, cx)
957                            }),
958                        ),
959                )
960        } else {
961            v_flex()
962                .on_action(
963                    cx.listener(|this, _: &menu::Confirm, _window, cx| {
964                        this.save_context_window(cx)
965                    }),
966                )
967                .child(self.context_window_editor.clone())
968                .gap_1p5()
969                .child(
970                    Label::new("Default: Model specific")
971                        .size(LabelSize::Small)
972                        .color(Color::Muted),
973                )
974        }
975    }
976
977    fn render_api_url_editor(&self, cx: &Context<Self>) -> Div {
978        let api_url = OllamaLanguageModelProvider::api_url(cx);
979        let custom_api_url_set = api_url != OLLAMA_API_URL;
980
981        if custom_api_url_set {
982            h_flex()
983                .p_1()
984                .justify_between()
985                .rounded_md()
986                .border_1()
987                .border_color(cx.theme().colors().border_variant)
988                .bg(cx.theme().colors().background.opacity(0.5))
989                .child(
990                    h_flex()
991                        .gap_1()
992                        .child(Icon::new(IconName::Check).color(Color::Success))
993                        .child(Label::new(api_url)),
994                )
995                .child(
996                    Button::new("reset-api-url", "Reset API URL")
997                        .style(ButtonStyle::Outlined)
998                        .label_size(LabelSize::Small)
999                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
1000                        .on_click(
1001                            cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)),
1002                        ),
1003                )
1004        } else {
1005            v_flex()
1006                .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
1007                    this.save_api_url(cx);
1008                    cx.notify();
1009                }))
1010                .gap_2()
1011                .child(self.api_url_editor.clone())
1012        }
1013    }
1014}
1015
1016impl Render for ConfigurationView {
1017    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1018        let is_authenticated = self.state.read(cx).is_authenticated();
1019
1020        v_flex()
1021            .gap_2()
1022            .child(Headline::new("Ollama").size(HeadlineSize::Small))
1023            .child(Self::render_instructions(cx))
1024            .child(self.render_api_url_editor(cx))
1025            .child(self.render_context_window_editor(cx))
1026            .child(self.render_api_key_editor(cx))
1027            .child(Divider::horizontal())
1028            .child(
1029                h_flex()
1030                    .pt_2()
1031                    .w_full()
1032                    .justify_between()
1033                    .gap_1()
1034                    .child(
1035                        h_flex()
1036                            .w_full()
1037                            .gap_2()
1038                            .map(|this| {
1039                                if is_authenticated {
1040                                    this.child(
1041                                        Button::new("ollama-site", "Ollama")
1042                                            .style(ButtonStyle::OutlinedGhost)
1043                                            .size(ButtonSize::Medium)
1044                                            .end_icon(
1045                                                Icon::new(IconName::ArrowUpRight)
1046                                                    .size(IconSize::XSmall)
1047                                                    .color(Color::Muted),
1048                                            )
1049                                            .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE))
1050                                            .into_any_element(),
1051                                    )
1052                                } else {
1053                                    this.child(
1054                                        Button::new("download_ollama_button", "Download Ollama")
1055                                            .style(ButtonStyle::OutlinedGhost)
1056                                            .size(ButtonSize::Medium)
1057                                            .end_icon(
1058                                                Icon::new(IconName::ArrowUpRight)
1059                                                    .size(IconSize::XSmall)
1060                                                    .color(Color::Muted),
1061                                            )
1062                                            .on_click(move |_, _, cx| {
1063                                                cx.open_url(OLLAMA_DOWNLOAD_URL)
1064                                            })
1065                                            .into_any_element(),
1066                                    )
1067                                }
1068                            })
1069                            .child(
1070                                Button::new("view-models", "View All Models")
1071                                    .style(ButtonStyle::OutlinedGhost)
1072                                    .size(ButtonSize::Medium)
1073                                    .end_icon(
1074                                        Icon::new(IconName::ArrowUpRight)
1075                                            .size(IconSize::XSmall)
1076                                            .color(Color::Muted),
1077                                    )
1078                                    .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)),
1079                            ),
1080                    )
1081                    .map(|this| {
1082                        if is_authenticated {
1083                            this.child(
1084                                ButtonLike::new("connected")
1085                                    .size(ButtonSize::Medium)
1086                                    .child(
1087                                        h_flex()
1088                                            .gap_1()
1089                                            .child(Icon::new(IconName::Check).color(Color::Success))
1090                                            .child(Label::new("Connected")),
1091                                    )
1092                                    .child(
1093                                        IconButton::new("refresh-models", IconName::RotateCcw)
1094                                            .icon_size(IconSize::Small)
1095                                            .tooltip(Tooltip::text("Refresh Models"))
1096                                            .on_click(cx.listener(|this, _, window, cx| {
1097                                                this.state.update(cx, |state, _| {
1098                                                    state.fetched_models.clear();
1099                                                });
1100                                                this.retry_connection(window, cx);
1101                                            })),
1102                                    ),
1103                            )
1104                        } else {
1105                            this.child(
1106                                Button::new("retry_ollama_models", "Connect")
1107                                    .style(ButtonStyle::Outlined)
1108                                    .size(ButtonSize::Medium)
1109                                    .start_icon(
1110                                        Icon::new(IconName::PlayOutlined).size(IconSize::XSmall),
1111                                    )
1112                                    .on_click(cx.listener(move |this, _, window, cx| {
1113                                        this.retry_connection(window, cx)
1114                                    })),
1115                            )
1116                        }
1117                    }),
1118            )
1119    }
1120}
1121
1122fn merge_settings_into_models(
1123    models: &mut HashMap<String, ollama::Model>,
1124    available_models: &[AvailableModel],
1125    context_window: Option<u64>,
1126) {
1127    for setting_model in available_models {
1128        if let Some(model) = models.get_mut(&setting_model.name) {
1129            if context_window.is_none() {
1130                model.max_tokens = setting_model.max_tokens;
1131            }
1132            model.display_name = setting_model.display_name.clone();
1133            model.keep_alive = setting_model.keep_alive.clone();
1134            model.supports_tools = setting_model.supports_tools;
1135            model.supports_vision = setting_model.supports_images;
1136            model.supports_thinking = setting_model.supports_thinking;
1137        } else {
1138            models.insert(
1139                setting_model.name.clone(),
1140                ollama::Model {
1141                    name: setting_model.name.clone(),
1142                    display_name: setting_model.display_name.clone(),
1143                    max_tokens: context_window.unwrap_or(setting_model.max_tokens),
1144                    keep_alive: setting_model.keep_alive.clone(),
1145                    supports_tools: setting_model.supports_tools,
1146                    supports_vision: setting_model.supports_images,
1147                    supports_thinking: setting_model.supports_thinking,
1148                    disabled: None,
1149                },
1150            );
1151        }
1152    }
1153}
1154
1155fn tool_into_ollama(tool: LanguageModelRequestTool) -> Result<ollama::OllamaTool> {
1156    let input_schema = match tool.input {
1157        language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
1158            input_schema
1159        }
1160        language_model::LanguageModelRequestToolInput::Custom { .. } => {
1161            anyhow::bail!("Ollama does not support custom tools");
1162        }
1163    };
1164    Ok(ollama::OllamaTool::Function {
1165        function: OllamaFunctionTool {
1166            name: tool.name,
1167            description: Some(tool.description),
1168            parameters: Some(input_schema),
1169        },
1170    })
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::*;
1176
1177    #[test]
1178    fn test_merge_settings_preserves_display_names_for_similar_models() {
1179        // Regression test for https://github.com/zed-industries/zed/issues/43646
1180        // When multiple models share the same base name (e.g., qwen2.5-coder:1.5b and qwen2.5-coder:3b),
1181        // each model should get its own display_name from settings, not a random one.
1182
1183        let mut models: HashMap<String, ollama::Model> = HashMap::default();
1184        models.insert(
1185            "qwen2.5-coder:1.5b".to_string(),
1186            ollama::Model {
1187                name: "qwen2.5-coder:1.5b".to_string(),
1188                display_name: None,
1189                max_tokens: 4096,
1190                keep_alive: None,
1191                supports_tools: None,
1192                supports_vision: None,
1193                supports_thinking: None,
1194                disabled: None,
1195            },
1196        );
1197        models.insert(
1198            "qwen2.5-coder:3b".to_string(),
1199            ollama::Model {
1200                name: "qwen2.5-coder:3b".to_string(),
1201                display_name: None,
1202                max_tokens: 4096,
1203                keep_alive: None,
1204                supports_tools: None,
1205                supports_vision: None,
1206                supports_thinking: None,
1207                disabled: None,
1208            },
1209        );
1210
1211        let available_models = vec![
1212            AvailableModel {
1213                name: "qwen2.5-coder:1.5b".to_string(),
1214                display_name: Some("QWEN2.5 Coder 1.5B".to_string()),
1215                max_tokens: 5000,
1216                keep_alive: None,
1217                supports_tools: Some(true),
1218                supports_images: None,
1219                supports_thinking: None,
1220            },
1221            AvailableModel {
1222                name: "qwen2.5-coder:3b".to_string(),
1223                display_name: Some("QWEN2.5 Coder 3B".to_string()),
1224                max_tokens: 6000,
1225                keep_alive: None,
1226                supports_tools: Some(true),
1227                supports_images: None,
1228                supports_thinking: None,
1229            },
1230        ];
1231
1232        merge_settings_into_models(&mut models, &available_models, None);
1233
1234        let model_1_5b = models
1235            .get("qwen2.5-coder:1.5b")
1236            .expect("1.5b model missing");
1237        let model_3b = models.get("qwen2.5-coder:3b").expect("3b model missing");
1238
1239        assert_eq!(
1240            model_1_5b.display_name,
1241            Some("QWEN2.5 Coder 1.5B".to_string()),
1242            "1.5b model should have its own display_name"
1243        );
1244        assert_eq!(model_1_5b.max_tokens, 5000);
1245
1246        assert_eq!(
1247            model_3b.display_name,
1248            Some("QWEN2.5 Coder 3B".to_string()),
1249            "3b model should have its own display_name"
1250        );
1251        assert_eq!(model_3b.max_tokens, 6000);
1252    }
1253}
1254
Served at tenant.openagents/omega Member data and write actions are omitted.