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