From 78c415f40c04c91aa301a89121cba1a73fc96eaa Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Thu, 7 May 2026 09:16:46 +0800 Subject: [PATCH] feat(provider): add Ollama provider support (#921) Source PR: #921 by @reidliu41. Closes #908. Local verification: - cargo test --workspace --all-features ollama - cargo fmt --all -- --check - cargo build Co-authored-by: reidliu41 --- README.md | 10 +- README.zh-CN.md | 10 +- config.example.toml | 11 +- crates/agent/src/lib.rs | 41 ++++++ crates/cli/src/lib.rs | 65 ++++++++- crates/config/src/lib.rs | 120 +++++++++++++++ crates/secrets/src/lib.rs | 15 ++ crates/tui/src/client.rs | 23 +++ crates/tui/src/commands/provider.rs | 16 +- crates/tui/src/config.rs | 202 ++++++++++++++++++++++++-- crates/tui/src/core/engine.rs | 1 + crates/tui/src/core/engine/tests.rs | 11 +- crates/tui/src/localization.rs | 10 +- crates/tui/src/main.rs | 19 +++ crates/tui/src/tui/provider_picker.rs | 44 +++++- crates/tui/src/tui/ui.rs | 3 + docs/CONFIGURATION.md | 25 +++- 17 files changed, 581 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 08d0399b..85728bc1 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,10 @@ SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model d # Self-hosted vLLM VLLM_BASE_URL="http://localhost:8000/v1" deepseek --provider vllm --model deepseek-v4-flash + +# Self-hosted Ollama +ollama pull deepseek-coder:1.3b +deepseek --provider ollama --model deepseek-coder:1.3b ``` --- @@ -306,12 +310,14 @@ Key environment variables: | `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | -| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `fireworks`, `sglang`, `vllm` | +| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `fireworks`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | -| `NVIDIA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | | `SGLANG_BASE_URL` | Self-hosted SGLang endpoint | | `VLLM_BASE_URL` | Self-hosted vLLM endpoint | +| `OLLAMA_BASE_URL` | Self-hosted Ollama endpoint | +| `OLLAMA_MODEL` | Self-hosted Ollama model tag | | `NO_ANIMATIONS=1` | Force accessibility mode at startup | | `SSL_CERT_FILE` | Custom CA bundle for corporate proxies | diff --git a/README.zh-CN.md b/README.zh-CN.md index a4d5b9e2..75960d29 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -172,6 +172,10 @@ SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model d # 自托管 vLLM VLLM_BASE_URL="http://localhost:8000/v1" deepseek --provider vllm --model deepseek-v4-flash + +# 自托管 Ollama +ollama pull deepseek-coder:1.3b +deepseek --provider ollama --model deepseek-coder:1.3b ``` --- @@ -256,12 +260,14 @@ deepseek update # 检查并应用二进制更新 | `DEEPSEEK_API_KEY` | DeepSeek API key | | `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_MODEL` | 默认模型 | -| `DEEPSEEK_PROVIDER` | `deepseek`(默认)、`nvidia-nim`、`fireworks`、`sglang`、`vllm` | +| `DEEPSEEK_PROVIDER` | `deepseek`(默认)、`nvidia-nim`、`fireworks`、`sglang`、`vllm`、`ollama` | | `DEEPSEEK_PROFILE` | 配置 profile 名称 | | `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 | -| `NVIDIA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` | 提供商认证 | +| `NVIDIA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | | `SGLANG_BASE_URL` | 自托管 SGLang 端点 | | `VLLM_BASE_URL` | 自托管 vLLM 端点 | +| `OLLAMA_BASE_URL` | 自托管 Ollama 端点 | +| `OLLAMA_MODEL` | 自托管 Ollama 模型标签 | | `NO_ANIMATIONS=1` | 启动时强制无障碍模式 | | `SSL_CERT_FILE` | 企业代理的自定义 CA 包 | diff --git a/config.example.toml b/config.example.toml index 5c442932..cb88f524 100644 --- a/config.example.toml +++ b/config.example.toml @@ -12,10 +12,10 @@ # Choose which provider to use by default. Per-provider credentials live in the # `[providers.*]` sections near the bottom of # this file — keeping both stored at once means `/provider deepseek` and -# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`, `/provider vllm`) toggle without having to +# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`, `/provider vllm`, `/provider ollama`) toggle without having to # re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek # defaults when `[providers.deepseek]` is absent (backward compatibility). -provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang | vllm +provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang | vllm | ollama api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com" # base_url = "https://api.deepseeki.com" # China users @@ -158,6 +158,7 @@ max_subagents = 10 # optional (1-20) # Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL # SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY # vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY +# Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY # DeepSeek Platform (https://platform.deepseek.com) [providers.deepseek] @@ -190,6 +191,12 @@ max_subagents = 10 # optional (1-20) # base_url = "http://localhost:8000/v1" # model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash +# Self-hosted Ollama OpenAI-compatible server +[providers.ollama] +# api_key = "OPTIONAL_OLLAMA_TOKEN" +# base_url = "http://localhost:11434/v1" +# model = "deepseek-coder:1.3b" # or any local Ollama tag + # ───────────────────────────────────────────────────────────────────────────────── # Network Policy (#135) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index bea66181..61f99973 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -185,6 +185,13 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "deepseek-coder:1.3b".to_string(), + provider: ProviderKind::Ollama, + aliases: vec![], + supports_tools: true, + supports_reasoning: false, + }, ]; Self::new(models) } @@ -218,6 +225,20 @@ impl ModelRegistry { if let Some(name) = requested { fallback_chain.push(format!("requested:{name}")); + if provider_hint == Some(ProviderKind::Ollama) { + return ModelResolution { + requested: Some(name.to_string()), + resolved: ModelInfo { + id: name.trim().to_string(), + provider: ProviderKind::Ollama, + aliases: Vec::new(), + supports_tools: true, + supports_reasoning: false, + }, + used_fallback: false, + fallback_chain, + }; + } if let Some(provider) = provider_hint && let Some(model) = self .models @@ -406,6 +427,26 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro"); } + #[test] + fn ollama_default_uses_small_local_model_id() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(None, Some(ProviderKind::Ollama)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.resolved.id, "deepseek-coder:1.3b"); + assert!(!resolved.resolved.supports_reasoning); + } + + #[test] + fn ollama_requested_model_tag_is_preserved() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b"); + assert!(!resolved.used_fallback); + } + #[test] fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() { let registry = ModelRegistry::default(); diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c3c8faf6..d9a0aac4 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -31,6 +31,7 @@ enum ProviderArg { Fireworks, Sglang, Vllm, + Ollama, } impl From for ProviderKind { @@ -44,6 +45,7 @@ impl From for ProviderKind { ProviderArg::Fireworks => ProviderKind::Fireworks, ProviderArg::Sglang => ProviderKind::Sglang, ProviderArg::Vllm => ProviderKind::Vllm, + ProviderArg::Ollama => ProviderKind::Ollama, } } } @@ -663,11 +665,12 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Fireworks => "fireworks", ProviderKind::Sglang => "sglang", ProviderKind::Vllm => "vllm", + ProviderKind::Ollama => "ollama", } } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 8] = [ +const PROVIDER_LIST: [ProviderKind; 9] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openrouter, @@ -675,6 +678,7 @@ const PROVIDER_LIST: [ProviderKind; 8] = [ ProviderKind::Fireworks, ProviderKind::Sglang, ProviderKind::Vllm, + ProviderKind::Ollama, ProviderKind::Openai, ]; @@ -795,6 +799,19 @@ fn run_auth_command_with_secrets( } => { let provider: ProviderKind = provider.into(); let slot = provider_slot(provider); + if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin { + store.config.provider = provider; + let provider_cfg = store.config.providers.for_provider_mut(provider); + if provider_cfg.base_url.is_none() { + provider_cfg.base_url = Some("http://localhost:11434/v1".to_string()); + } + store.save()?; + println!( + "configured {slot} provider in {} (API key optional)", + store.path().display() + ); + return Ok(()); + } let api_key = match (api_key, api_key_stdin) { (Some(v), _) => v, (None, true) => read_api_key_from_stdin()?, @@ -1284,9 +1301,10 @@ fn build_tui_command( | ProviderKind::Fireworks | ProviderKind::Sglang | ProviderKind::Vllm + | ProviderKind::Ollama ) { bail!( - "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.", + "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.", resolved_runtime.provider.as_str() ); } @@ -1892,6 +1910,18 @@ mod tests { })) )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Ollama, + api_key: None, + api_key_stdin: false, + } + })) + )); + let cli = parse_ok(&["deepseek", "auth", "list"]); assert!(matches!( cli.command, @@ -1957,6 +1987,37 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn auth_set_ollama_accepts_empty_key_and_records_base_url() { + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-auth-ollama-test-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + let secrets = no_keyring_secrets(); + + run_auth_command_with_secrets( + &mut store, + AuthCommand::Set { + provider: ProviderArg::Ollama, + api_key: None, + api_key_stdin: false, + }, + &secrets, + ) + .expect("ollama auth set should not require a key"); + + assert_eq!(store.config.provider, ProviderKind::Ollama); + assert_eq!( + store.config.providers.ollama.base_url.as_deref(), + Some("http://localhost:11434/v1") + ); + assert_eq!(store.config.providers.ollama.api_key, None); + + let _ = std::fs::remove_file(path); + } + #[test] fn auth_clear_removes_from_config() { use deepseek_secrets::{InMemoryKeyringStore, KeyringStore}; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 48077512..349c9400 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -35,6 +35,8 @@ const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1"; +const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b"; +const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1"; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] @@ -48,6 +50,7 @@ pub enum ProviderKind { Fireworks, Sglang, Vllm, + Ollama, } impl ProviderKind { @@ -62,6 +65,7 @@ impl ProviderKind { Self::Fireworks => "fireworks", Self::Sglang => "sglang", Self::Vllm => "vllm", + Self::Ollama => "ollama", } } @@ -76,6 +80,7 @@ impl ProviderKind { "fireworks" | "fireworks-ai" => Some(Self::Fireworks), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), + "ollama" | "ollama-local" => Some(Self::Ollama), _ => None, } } @@ -108,6 +113,8 @@ pub struct ProvidersToml { pub sglang: ProviderConfigToml, #[serde(default)] pub vllm: ProviderConfigToml, + #[serde(default)] + pub ollama: ProviderConfigToml, } impl ProvidersToml { @@ -122,6 +129,7 @@ impl ProvidersToml { ProviderKind::Fireworks => &self.fireworks, ProviderKind::Sglang => &self.sglang, ProviderKind::Vllm => &self.vllm, + ProviderKind::Ollama => &self.ollama, } } @@ -135,6 +143,7 @@ impl ProvidersToml { ProviderKind::Fireworks => &mut self.fireworks, ProviderKind::Sglang => &mut self.sglang, ProviderKind::Vllm => &mut self.vllm, + ProviderKind::Ollama => &mut self.ollama, } } } @@ -343,6 +352,7 @@ impl ConfigToml { merge_provider_config(&mut self.providers.fireworks, &project.providers.fireworks); merge_provider_config(&mut self.providers.sglang, &project.providers.sglang); merge_provider_config(&mut self.providers.vllm, &project.providers.vllm); + merge_provider_config(&mut self.providers.ollama, &project.providers.ollama); if project.network.is_some() { self.network = project.network; @@ -426,6 +436,12 @@ impl ConfigToml { "providers.vllm.http_headers" => { serialize_http_headers(&self.providers.vllm.http_headers) } + "providers.ollama.api_key" => self.providers.ollama.api_key.clone(), + "providers.ollama.base_url" => self.providers.ollama.base_url.clone(), + "providers.ollama.model" => self.providers.ollama.model.clone(), + "providers.ollama.http_headers" => { + serialize_http_headers(&self.providers.ollama.http_headers) + } _ => self.extras.get(key).map(toml::Value::to_string), } } @@ -549,6 +565,18 @@ impl ConfigToml { "providers.vllm.http_headers" => { self.providers.vllm.http_headers = parse_http_headers(value)?; } + "providers.ollama.api_key" => { + self.providers.ollama.api_key = Some(value.to_string()); + } + "providers.ollama.base_url" => { + self.providers.ollama.base_url = Some(value.to_string()); + } + "providers.ollama.model" => { + self.providers.ollama.model = Some(value.to_string()); + } + "providers.ollama.http_headers" => { + self.providers.ollama.http_headers = parse_http_headers(value)?; + } _ => { self.extras .insert(key.to_string(), toml::Value::String(value.to_string())); @@ -617,6 +645,10 @@ impl ConfigToml { "providers.vllm.base_url" => self.providers.vllm.base_url = None, "providers.vllm.model" => self.providers.vllm.model = None, "providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(), + "providers.ollama.api_key" => self.providers.ollama.api_key = None, + "providers.ollama.base_url" => self.providers.ollama.base_url = None, + "providers.ollama.model" => self.providers.ollama.model = None, + "providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(), _ => { self.extras.remove(key); } @@ -764,6 +796,18 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.vllm.http_headers) { out.insert("providers.vllm.http_headers".to_string(), v); } + if let Some(v) = self.providers.ollama.api_key.as_ref() { + out.insert("providers.ollama.api_key".to_string(), redact_secret(v)); + } + if let Some(v) = self.providers.ollama.base_url.as_ref() { + out.insert("providers.ollama.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.ollama.model.as_ref() { + out.insert("providers.ollama.model".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.ollama.http_headers) { + out.insert("providers.ollama.http_headers".to_string(), v); + } for (k, v) in &self.extras { out.insert(k.clone(), v.to_string()); @@ -841,6 +885,7 @@ impl ConfigToml { ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(), ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(), ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), + ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); let model = cli @@ -859,6 +904,7 @@ impl ConfigToml { ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL.to_string(), ProviderKind::Sglang => DEFAULT_SGLANG_MODEL.to_string(), ProviderKind::Vllm => DEFAULT_VLLM_MODEL.to_string(), + ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL.to_string(), }); let model = normalize_model_for_provider(provider, &model); @@ -944,6 +990,10 @@ pub fn load_project_config(workspace: &Path) -> Option { } fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { + if matches!(provider, ProviderKind::Ollama) { + return model.to_string(); + } + let normalized = model.trim().to_ascii_lowercase(); match (provider, normalized.as_str()) { (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => { @@ -1222,6 +1272,7 @@ struct EnvRuntimeOverrides { fireworks_base_url: Option, sglang_base_url: Option, vllm_base_url: Option, + ollama_base_url: Option, } impl EnvRuntimeOverrides { @@ -1269,6 +1320,9 @@ impl EnvRuntimeOverrides { vllm_base_url: std::env::var("VLLM_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + ollama_base_url: std::env::var("OLLAMA_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), } } @@ -1284,6 +1338,7 @@ impl EnvRuntimeOverrides { ProviderKind::Fireworks => self.fireworks_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), ProviderKind::Vllm => self.vllm_base_url.clone(), + ProviderKind::Ollama => self.ollama_base_url.clone(), } } } @@ -1321,6 +1376,8 @@ mod tests { sglang_base_url: Option, vllm_api_key: Option, vllm_base_url: Option, + ollama_api_key: Option, + ollama_base_url: Option, } impl EnvGuard { @@ -1346,6 +1403,8 @@ mod tests { sglang_base_url: env::var_os("SGLANG_BASE_URL"), vllm_api_key: env::var_os("VLLM_API_KEY"), vllm_base_url: env::var_os("VLLM_BASE_URL"), + ollama_api_key: env::var_os("OLLAMA_API_KEY"), + ollama_base_url: env::var_os("OLLAMA_BASE_URL"), }; // Safety: test-only environment mutation guarded by a module mutex. unsafe { @@ -1369,6 +1428,8 @@ mod tests { env::remove_var("SGLANG_BASE_URL"); env::remove_var("VLLM_API_KEY"); env::remove_var("VLLM_BASE_URL"); + env::remove_var("OLLAMA_API_KEY"); + env::remove_var("OLLAMA_BASE_URL"); } guard } @@ -1406,6 +1467,8 @@ mod tests { Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); + Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); + Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); } } } @@ -1705,6 +1768,11 @@ mod tests { assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang)); assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm)); assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm)); + assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama)); + assert_eq!( + ProviderKind::parse("ollama-local"), + Some(ProviderKind::Ollama) + ); } #[test] @@ -1787,6 +1855,58 @@ mod tests { assert_eq!(resolved.model, DEFAULT_VLLM_MODEL); } + #[test] + fn ollama_provider_defaults_to_local_endpoint_and_small_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Ollama, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL); + assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL); + assert_eq!(resolved.api_key, None); + } + + #[test] + fn ollama_provider_preserves_model_tags() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Ollama), + model: Some("deepseek-coder-v2:16b".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.model, "deepseek-coder-v2:16b"); + } + + #[test] + fn ollama_env_overrides_provider_base_url_and_optional_key() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "ollama-local"); + env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1"); + env::set_var("OLLAMA_API_KEY", "ollama-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.base_url, "http://ollama.example/v1"); + assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key")); + } + #[test] fn openrouter_env_api_key_falls_back_when_config_missing() { let _lock = env_lock(); diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 911f769e..2207ad6c 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -435,6 +435,7 @@ pub fn env_for(name: &str) -> Option { "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"], "sglang" | "sg-lang" => &["SGLANG_API_KEY"], "vllm" | "v-llm" => &["VLLM_API_KEY"], + "ollama" | "ollama-local" => &["OLLAMA_API_KEY"], "openai" => &["OPENAI_API_KEY"], _ => return None, }; @@ -472,6 +473,7 @@ mod tests { "FIREWORKS_API_KEY", "SGLANG_API_KEY", "VLLM_API_KEY", + "OLLAMA_API_KEY", "OPENAI_API_KEY", ] { // Safety: tests serialise on env_lock(); the broader @@ -609,6 +611,19 @@ mod tests { unsafe { std::env::remove_var("VLLM_API_KEY") }; } + #[test] + fn ollama_env_aliases_resolve() { + let _lock = env_lock(); + clear_known_envs(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") }; + + assert_eq!(env_for("ollama").as_deref(), Some("ollama-key")); + assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key")); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("OLLAMA_API_KEY") }; + } + #[cfg(unix)] #[test] fn file_store_round_trips_with_secure_perms() { diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index bfa6719d..fbe29c5c 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -799,6 +799,7 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Vllm => { body["thinking"] = json!({ "type": "disabled" }); } + ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": false, @@ -816,6 +817,7 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } + ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": true, @@ -834,6 +836,7 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } + ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": true, @@ -1745,6 +1748,26 @@ mod tests { ); } + #[test] + fn parse_models_response_accepts_ollama_tag_ids() { + let payload = r#"{ + "object": "list", + "data": [ + {"id": "qwen2.5-coder:7b", "object": "model", "owned_by": "library"}, + {"id": "deepseek-coder-v2:16b", "object": "model"} + ] + }"#; + + let models = parse_models_response(payload).expect("parse models"); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["deepseek-coder-v2:16b", "qwen2.5-coder:7b"] + ); + } + #[test] fn parse_usage_reads_deepseek_cache_and_reasoning_tokens() { let usage = parse_usage(Some(&json!({ diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 5126649f..ec7dd0dc 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -27,12 +27,13 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let Some(target) = ApiProvider::parse(name) else { return CommandResult::error(format!( - "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, sglang, or vllm." + "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, sglang, vllm, or ollama." )); }; let model = match model_arg { None => None, + Some(raw) if target == ApiProvider::Ollama => Some(raw.trim().to_string()), Some(raw) => match normalize_model_name(&expand_model_alias(raw)) { Some(normalized) => Some(normalized), None => { @@ -180,6 +181,19 @@ mod tests { } } + #[test] + fn switch_to_ollama_preserves_model_tag() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("ollama qwen2.5-coder:7b")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Ollama); + assert_eq!(model.as_deref(), Some("qwen2.5-coder:7b")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switching_to_active_provider_without_model_is_a_noop() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index cc139bd1..4de50503 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -37,6 +37,8 @@ pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; pub const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; pub const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; pub const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1"; +pub const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b"; +pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1"; pub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = "https://api.deepseeki.com"; const API_KEYRING_SENTINEL: &str = "__KEYRING__"; pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[ @@ -59,6 +61,7 @@ pub enum ApiProvider { Fireworks, Sglang, Vllm, + Ollama, } impl ApiProvider { @@ -75,6 +78,7 @@ impl ApiProvider { "fireworks" | "fireworks-ai" => Some(Self::Fireworks), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), + "ollama" | "ollama-local" => Some(Self::Ollama), _ => None, } } @@ -90,6 +94,7 @@ impl ApiProvider { Self::Fireworks => "fireworks", Self::Sglang => "sglang", Self::Vllm => "vllm", + Self::Ollama => "ollama", } } @@ -105,6 +110,7 @@ impl ApiProvider { Self::Fireworks => "Fireworks AI", Self::Sglang => "SGLang", Self::Vllm => "vLLM", + Self::Ollama => "Ollama", } } @@ -120,6 +126,7 @@ impl ApiProvider { Self::Fireworks, Self::Sglang, Self::Vllm, + Self::Ollama, ] } } @@ -165,6 +172,18 @@ pub enum RequestPayloadMode { /// in the API payload (after normalization / provider-specific mapping). #[must_use] pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability { + if matches!(provider, ApiProvider::Ollama) { + return ProviderCapability { + provider, + resolved_model: resolved_model.to_string(), + context_window: 8192, + max_output: 4096, + thinking_supported: false, + cache_telemetry_supported: false, + request_payload_mode: RequestPayloadMode::ChatCompletions, + }; + } + let model_lower = resolved_model.to_ascii_lowercase(); let is_v4_pro = model_lower.contains("v4-pro") || model_lower == "deepseek-v4pro"; let is_v4_flash = model_lower.contains("v4-flash") @@ -940,6 +959,8 @@ pub struct ProvidersConfig { pub sglang: ProviderConfig, #[serde(default)] pub vllm: ProviderConfig, + #[serde(default)] + pub ollama: ProviderConfig, } #[derive(Debug, Clone, Deserialize, Default)] @@ -999,7 +1020,7 @@ impl Config { && ApiProvider::parse(provider).is_none() { anyhow::bail!( - "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openrouter, novita, fireworks, sglang, or vllm." + "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openrouter, novita, fireworks, sglang, vllm, or ollama." ); } if let Some(ref key) = self.api_key @@ -1016,6 +1037,7 @@ impl Config { } if let Some(model) = self.default_text_model.as_deref() && !model.trim().eq_ignore_ascii_case("auto") + && !matches!(self.api_provider(), ApiProvider::Ollama) && normalize_model_name(model).is_none() { anyhow::bail!( @@ -1118,6 +1140,7 @@ impl Config { ApiProvider::Fireworks => &providers.fireworks, ApiProvider::Sglang => &providers.sglang, ApiProvider::Vllm => &providers.vllm, + ApiProvider::Ollama => &providers.ollama, }) } @@ -1144,9 +1167,18 @@ impl Config { if let Some(model) = self .provider_config() .and_then(|provider| provider.model.as_deref()) - && let Some(normalized) = normalize_model_for_provider(provider, model) { - return normalized; + if matches!(provider, ApiProvider::Ollama) { + return model.trim().to_string(); + } + if let Some(normalized) = normalize_model_for_provider(provider, model) { + return normalized; + } + } + if let Some(model) = self.default_text_model.as_deref() + && matches!(provider, ApiProvider::Ollama) + { + return model.trim().to_string(); } if let Some(model) = self.default_text_model.as_deref() && model.trim().eq_ignore_ascii_case("auto") @@ -1167,6 +1199,7 @@ impl Config { ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL, ApiProvider::Sglang => DEFAULT_SGLANG_MODEL, ApiProvider::Vllm => DEFAULT_VLLM_MODEL, + ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL, } .to_string() } @@ -1193,7 +1226,8 @@ impl Config { | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang - | ApiProvider::Vllm => None, + | ApiProvider::Vllm + | ApiProvider::Ollama => None, }; let base = provider_base.or(root_base).unwrap_or_else(|| { match provider { @@ -1205,6 +1239,7 @@ impl Config { ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, + ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, } .to_string() }); @@ -1229,6 +1264,7 @@ impl Config { ApiProvider::Fireworks => "fireworks", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", + ApiProvider::Ollama => "ollama", }; // 0. Explicit in-memory override (set by onboarding / provider @@ -1289,10 +1325,9 @@ impl Config { "Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \ set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml." ), - // Self-hosted SGLang deployments commonly run without auth on - // localhost. Return an empty key and let the client omit the - // Authorization header. - ApiProvider::Sglang | ApiProvider::Vllm => Ok(String::new()), + // Self-hosted deployments commonly run without auth on localhost. + // Return an empty key and let the client omit the Authorization header. + ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()), } } @@ -1842,11 +1877,22 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Fireworks => &mut providers.fireworks, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, + ApiProvider::Ollama => &mut providers.ollama, }; let mut provider_headers = entry.http_headers.clone().unwrap_or_default(); provider_headers.extend(headers); entry.http_headers = Some(provider_headers); } + if matches!(config.api_provider(), ApiProvider::Ollama) + && let Ok(value) = std::env::var("OLLAMA_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .ollama + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Sglang) && let Ok(value) = std::env::var("SGLANG_MODEL") { @@ -1857,6 +1903,11 @@ fn apply_env_overrides(config: &mut Config) { { config.default_text_model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Ollama) + && let Ok(value) = std::env::var("OLLAMA_MODEL") + { + config.default_text_model = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) { @@ -2035,6 +2086,7 @@ fn apply_env_overrides(config: &mut Config) { fn normalize_model_config(config: &mut Config) { if let Some(model) = config.default_text_model.as_deref() + && !matches!(config.api_provider(), ApiProvider::Ollama) && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model) { config.default_text_model = Some(normalized); @@ -2085,6 +2137,9 @@ fn normalize_model_config(config: &mut Config) { } fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option { + if matches!(provider, ApiProvider::Ollama) { + return None; + } normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized)) } @@ -2274,6 +2329,7 @@ fn merge_providers( fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), sglang: merge_provider_config(base.sglang, override_cfg.sglang), vllm: merge_provider_config(base.vllm, override_cfg.vllm), + ollama: merge_provider_config(base.ollama, override_cfg.ollama), }), } } @@ -2692,6 +2748,7 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { } ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()), + ApiProvider::Ollama => std::env::var("OLLAMA_API_KEY").is_ok_and(|k| !k.trim().is_empty()), } } @@ -2713,6 +2770,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Fireworks => "FIREWORKS_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", + ApiProvider::Ollama => "OLLAMA_API_KEY", }; if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) { return true; @@ -2723,8 +2781,11 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { return true; } - // SGLang is self-hosted and typically runs without authentication. - if matches!(provider, ApiProvider::Sglang | ApiProvider::Vllm) { + // Self-hosted providers typically run without authentication. + if matches!( + provider, + ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama + ) { return true; } @@ -2776,6 +2837,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Fireworks => "providers.fireworks", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", + ApiProvider::Ollama => "providers.ollama", }; // Parse existing TOML (or start fresh) so we can edit the right table @@ -2808,6 +2870,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Fireworks => "fireworks", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", + ApiProvider::Ollama => "ollama", }; let entry = providers .entry(key_inside.to_string()) @@ -2927,6 +2990,9 @@ mod tests { vllm_api_key: Option, vllm_base_url: Option, vllm_model: Option, + ollama_api_key: Option, + ollama_base_url: Option, + ollama_model: Option, } impl EnvGuard { @@ -2961,6 +3027,9 @@ mod tests { let vllm_api_key_prev = env::var_os("VLLM_API_KEY"); let vllm_base_url_prev = env::var_os("VLLM_BASE_URL"); let vllm_model_prev = env::var_os("VLLM_MODEL"); + let ollama_api_key_prev = env::var_os("OLLAMA_API_KEY"); + let ollama_base_url_prev = env::var_os("OLLAMA_BASE_URL"); + let ollama_model_prev = env::var_os("OLLAMA_MODEL"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -2990,6 +3059,9 @@ mod tests { env::remove_var("VLLM_API_KEY"); env::remove_var("VLLM_BASE_URL"); env::remove_var("VLLM_MODEL"); + env::remove_var("OLLAMA_API_KEY"); + env::remove_var("OLLAMA_BASE_URL"); + env::remove_var("OLLAMA_MODEL"); } Self { home: home_prev, @@ -3019,6 +3091,9 @@ mod tests { vllm_api_key: vllm_api_key_prev, vllm_base_url: vllm_base_url_prev, vllm_model: vllm_model_prev, + ollama_api_key: ollama_api_key_prev, + ollama_base_url: ollama_base_url_prev, + ollama_model: ollama_model_prev, } } } @@ -3057,6 +3132,9 @@ mod tests { Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); Self::restore_var("VLLM_MODEL", self.vllm_model.take()); + Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); + Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); + Self::restore_var("OLLAMA_MODEL", self.ollama_model.take()); } } } @@ -4221,6 +4299,97 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } Ok(()) } + #[test] + fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-ollama-defaults-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("ollama".to_string()), + ..Default::default() + }; + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Ollama); + assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL); + assert_eq!(config.deepseek_api_key()?, ""); + assert!(has_api_key_for(&config, ApiProvider::Ollama)); + Ok(()) + } + + #[test] + fn ollama_model_is_passed_through_verbatim() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-ollama-model-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "ollama" + +[providers.ollama] +base_url = "http://127.0.0.1:11434/v1" +model = "qwen2.5-coder:7b" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Ollama); + assert_eq!(config.default_model(), "qwen2.5-coder:7b"); + assert_eq!(config.deepseek_base_url(), "http://127.0.0.1:11434/v1"); + Ok(()) + } + + #[test] + fn ollama_env_overrides_base_url_and_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-ollama-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "ollama-local"); + env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1"); + env::set_var("OLLAMA_MODEL", "deepseek-coder-v2:16b"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Ollama); + assert_eq!(config.deepseek_base_url(), "http://ollama.example/v1"); + assert_eq!(config.default_model(), "deepseek-coder-v2:16b"); + Ok(()) + } + #[test] fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> { let _lock = lock_test_env(); @@ -4685,6 +4854,19 @@ model = "deepseek-v4-pro" assert!(!cap.cache_telemetry_supported); } + #[test] + fn provider_capability_ollama_is_openai_compatible_without_thinking() { + let cap = provider_capability(ApiProvider::Ollama, "deepseek-v3.1:671b"); + assert_eq!(cap.context_window, 8192); + assert_eq!(cap.max_output, 4096); + assert!(!cap.thinking_supported); + assert!(!cap.cache_telemetry_supported); + assert_eq!( + cap.request_payload_mode, + RequestPayloadMode::ChatCompletions + ); + } + #[test] fn provider_capability_non_v4_model_has_smaller_window() { let cap = provider_capability(ApiProvider::Deepseek, "deepseek-coder"); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 4e480e78..5d5b4981 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -375,6 +375,7 @@ impl Engine { ApiProvider::Fireworks => "FIREWORKS_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", + ApiProvider::Ollama => "OLLAMA_API_KEY", }; Some(format!( diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 18c0b9be..03f5edd0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::SystemBlock; +use crate::test_support::lock_test_env; use serde_json::json; use std::collections::HashSet; use std::ffi::OsString; @@ -13,8 +14,6 @@ use tempfile::tempdir; const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set"; static CAPACITY_MEMORY_ENV_LOCK: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); -static API_KEY_ENV_LOCK: LazyLock> = - LazyLock::new(|| std::sync::Mutex::new(())); struct ScopedCapacityMemoryDir { previous: Option, @@ -52,7 +51,7 @@ struct ScopedDeepSeekApiKey { impl ScopedDeepSeekApiKey { fn set(value: &str) -> Self { let previous = std::env::var_os("DEEPSEEK_API_KEY"); - // Safety: tests using this helper serialize with API_KEY_ENV_LOCK and + // Safety: tests using this helper serialize with lock_test_env() and // restore the original value in Drop. unsafe { std::env::set_var("DEEPSEEK_API_KEY", value); @@ -63,7 +62,7 @@ impl ScopedDeepSeekApiKey { impl Drop for ScopedDeepSeekApiKey { fn drop(&mut self) { - // Safety: tests using this helper serialize with API_KEY_ENV_LOCK. + // Safety: tests using this helper serialize with lock_test_env(). unsafe { if let Some(previous) = self.previous.take() { std::env::set_var("DEEPSEEK_API_KEY", previous); @@ -85,7 +84,7 @@ fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine { #[test] fn env_only_auth_error_gets_recovery_hint() { - let _guard = API_KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = lock_test_env(); let _env = ScopedDeepSeekApiKey::set("stale-env-key"); let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); @@ -99,7 +98,7 @@ fn env_only_auth_error_gets_recovery_hint() { #[test] fn config_auth_error_does_not_blame_env() { - let _guard = API_KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = lock_test_env(); let _env = ScopedDeepSeekApiKey::set("stale-env-key"); let cfg = Config { api_key: Some("fresh-config-key".to_string()), diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index b6992720..02fee24d 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -756,7 +756,7 @@ fn english(id: MessageId) -> &'static str { "Switch to plan mode and review suggested implementation steps" } MessageId::CmdProviderDescription => { - "Switch or view the active LLM backend (deepseek | nvidia-nim)" + "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "View or edit queued messages", MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)", @@ -1036,7 +1036,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdNoteDescription => "永続ノートファイル(.deepseek/notes.md)に追記", MessageId::CmdPlanDescription => "Plan モードに切り替え、推奨される実装手順を確認", MessageId::CmdProviderDescription => { - "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim)" + "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "キューされたメッセージを確認・編集", MessageId::CmdRecallDescription => { @@ -1299,7 +1299,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdNetworkDescription => "管理网络允许和拒绝规则", MessageId::CmdNoteDescription => "将笔记追加到持久笔记文件(.deepseek/notes.md)", MessageId::CmdPlanDescription => "切换到 Plan 模式并查看建议的实现步骤", - MessageId::CmdProviderDescription => "切换或查看当前 LLM 后端(deepseek | nvidia-nim)", + MessageId::CmdProviderDescription => { + "切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)" + } MessageId::CmdQueueDescription => "查看或编辑已排队的消息", MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)", MessageId::CmdRenameDescription => "重命名当前会话", @@ -1558,7 +1560,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Mudar para o modo plan e revisar os passos de implementação sugeridos" } MessageId::CmdProviderDescription => { - "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim)" + "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver ou editar mensagens enfileiradas", MessageId::CmdRecallDescription => { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index cadfa8b8..8f55bfdd 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1355,6 +1355,9 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "VLLM_API_KEY", "deepseek auth set --provider vllm --api-key \"...\"", ), + crate::config::ApiProvider::Ollama => { + ("OLLAMA_API_KEY", "deepseek auth set --provider ollama") + } crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => { ("DEEPSEEK_API_KEY", "deepseek auth set --provider deepseek") } @@ -1369,6 +1372,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Fireworks => "fireworks", crate::config::ApiProvider::Sglang => "sglang", crate::config::ApiProvider::Vllm => "vllm", + crate::config::ApiProvider::Ollama => "ollama", crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => "deepseek", } @@ -1605,6 +1609,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "vllm", &["VLLM_API_KEY"][..], ), + ( + crate::config::ApiProvider::Ollama, + "ollama", + &["OLLAMA_API_KEY"][..], + ), ] { let in_env = env_names.iter().any(|n| { std::env::var(n) @@ -1646,6 +1655,16 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt ApiKeySource::Config => "config.toml", ApiKeySource::Keyring => "OS keyring", ApiKeySource::Env => "environment", + ApiKeySource::Missing + if matches!( + config.api_provider(), + crate::config::ApiProvider::Sglang + | crate::config::ApiProvider::Vllm + | crate::config::ApiProvider::Ollama + ) => + { + "optional local auth" + } ApiKeySource::Missing => "unknown source", }; println!( diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 9eead1b6..6eefa501 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -1,5 +1,5 @@ //! `/provider` picker modal — pick a provider (DeepSeek / NVIDIA NIM / -//! OpenRouter / Novita) and, if it lacks credentials, type the API key +//! hosted providers / self-hosted providers) and, if it lacks credentials, type the API key //! inline before completing the switch (#52). //! //! The picker is intentionally a single modal with two visible states: @@ -93,6 +93,19 @@ impl ProviderPickerView { ApiProvider::Fireworks => "FIREWORKS_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", + ApiProvider::Ollama => "OLLAMA_API_KEY", + } + } + + fn provider_hint(provider: ApiProvider, has_key: bool) -> String { + match provider { + ApiProvider::Ollama => "self-hosted; defaults to http://localhost:11434".to_string(), + ApiProvider::Sglang | ApiProvider::Vllm if has_key => { + "(configured; optional key)".to_string() + } + ApiProvider::Sglang | ApiProvider::Vllm => "(optional key)".to_string(), + _ if has_key => "(configured)".to_string(), + _ => "(needs API key)".to_string(), } } @@ -141,11 +154,7 @@ impl ProviderPickerView { } else { Style::default().fg(palette::STATUS_WARNING) }; - let hint = if *has_key { - "(configured)".to_string() - } else { - "(needs API key)".to_string() - }; + let hint = Self::provider_hint(*provider, *has_key); lines.push(Line::from(vec![ Span::raw(" "), Span::styled(arrow, label_style), @@ -358,7 +367,7 @@ mod tests { } #[test] - fn picker_lists_all_seven_providers() { + fn picker_lists_all_providers() { let config = Config::default(); let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); let names: Vec<_> = picker @@ -376,11 +385,30 @@ mod tests { "Novita AI", "Fireworks AI", "SGLang", - "vLLM" + "vLLM", + "Ollama" ] ); } + #[test] + fn ollama_is_selectable_without_key() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + for _ in 0..8 { + picker.handle_key(key(KeyCode::Down)); + } + assert_eq!(picker.selected_provider(), ApiProvider::Ollama); + assert!(picker.selected_has_key()); + let action = picker.handle_key(key(KeyCode::Enter)); + match action { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) => { + assert_eq!(provider, ApiProvider::Ollama); + } + other => panic!("expected ProviderPickerApplied, got {other:?}"), + } + } + #[test] fn picker_marks_active_provider_as_initial_selection() { let config = Config::default(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a6283172..2bcd3b73 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4698,6 +4698,7 @@ async fn execute_command_input( providers.fireworks.api_key = None; providers.sglang.api_key = None; providers.vllm.api_key = None; + providers.ollama.api_key = None; } app.api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config); } @@ -5076,6 +5077,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Fireworks => Some("Fireworks"), crate::config::ApiProvider::Sglang => Some("SGLang"), crate::config::ApiProvider::Vllm => Some("vLLM"), + crate::config::ApiProvider::Ollama => Some("Ollama"), }; let header_data = HeaderData::new( app.mode, @@ -5706,6 +5708,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Fireworks => &mut providers.fireworks, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, + ApiProvider::Ollama => &mut providers.ollama, }; entry.api_key = Some(api_key); } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9ce0b494..c78fbd3e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -55,14 +55,15 @@ the legacy `deepseek login --api-key ...` alias) saves the key to `~/.deepseek/config.toml`, and `deepseek --model deepseek-v4-flash` is forwarded to the TUI as `DEEPSEEK_MODEL`. -For hosted or self-hosted DeepSeek V4 providers, set `provider = "nvidia-nim"`, -`"fireworks"`, `"sglang"`, or `"vllm"` or pass `deepseek --provider `. The facade +For hosted or self-hosted providers, set `provider = "nvidia-nim"`, +`"fireworks"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass `deepseek --provider `. The facade saves provider credentials to the shared user config and forwards the resolved key, base URL, provider, and model to the TUI process. Use `deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or `deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to -save hosted-provider keys through the facade. SGLang and vLLM are self-hosted and can run -without an API key by default. +save hosted-provider keys through the facade. SGLang, vLLM, and Ollama are self-hosted and can run +without an API key by default. Ollama defaults to `http://localhost:11434/v1` and sends model tags +such as `deepseek-coder:1.3b` or `qwen2.5-coder:7b` unchanged. Third-party OpenAI-compatible gateways that need extra request headers can set `http_headers = { "X-Model-Provider-Id" = "your-model-provider" }` at the top @@ -114,6 +115,11 @@ default_text_model = "deepseek-ai/DeepSeek-V4-Pro" provider = "vllm" base_url = "http://localhost:8000/v1" default_text_model = "deepseek-ai/DeepSeek-V4-Pro" + +[profiles.ollama] +provider = "ollama" +base_url = "http://localhost:11434/v1" +default_text_model = "deepseek-coder:1.3b" ``` Select a profile with: @@ -130,7 +136,7 @@ These override config values: - `DEEPSEEK_API_KEY` - `DEEPSEEK_BASE_URL` - `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs) -- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang|vllm`) +- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang|vllm|ollama`) - `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL` - `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`) - `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL` @@ -143,6 +149,9 @@ These override config values: - `VLLM_BASE_URL` - `VLLM_MODEL` - `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth) +- `OLLAMA_BASE_URL` +- `OLLAMA_MODEL` +- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth) - `DEEPSEEK_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs) - `DEEPSEEK_SKILLS_DIR` - `DEEPSEEK_MCP_CONFIG` @@ -316,10 +325,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `deepseek-cn`, `nvidia-nim`, `openrouter`, `novita`, `fireworks`, `sglang`, or `vllm`. `deepseek-cn` uses DeepSeek's mainland China endpoint (`https://api.deepseeki.com`); `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`. -- `api_key` (string, required): must be non-empty (or set `DEEPSEEK_API_KEY`). +- `provider` (string, optional): `deepseek` (default), `deepseek-cn`, `nvidia-nim`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. `deepseek-cn` uses DeepSeek's mainland China endpoint (`https://api.deepseeki.com`); `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `base_url` (string, optional): defaults to `https://api.deepseek.com` for DeepSeek's OpenAI-compatible Chat Completions API, `https://api.deepseeki.com` for `provider = "deepseek-cn"`, or the provider-specific endpoint for hosted/self-hosted providers. `https://api.deepseek.com/v1` is also accepted for SDK compatibility; use `https://api.deepseek.com/beta` only for DeepSeek beta features such as strict tool mode, chat prefix completion, and FIM completion. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, and `deepseek-ai/DeepSeek-V4-Pro` for SGLang. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Ollama model tags are passed through unchanged. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.