Skip to repository content1254 lines · 48.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:48:50.577Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
ollama.rs
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 != ¤t_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