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 <reid201711@gmail.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
+8
-2
@@ -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 包 |
|
||||
|
||||
|
||||
+9
-2
@@ -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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
|
||||
+63
-2
@@ -31,6 +31,7 @@ enum ProviderArg {
|
||||
Fireworks,
|
||||
Sglang,
|
||||
Vllm,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl From<ProviderArg> for ProviderKind {
|
||||
@@ -44,6 +45,7 @@ impl From<ProviderArg> 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};
|
||||
|
||||
@@ -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<ConfigToml> {
|
||||
}
|
||||
|
||||
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<String>,
|
||||
sglang_base_url: Option<String>,
|
||||
vllm_base_url: Option<String>,
|
||||
ollama_base_url: Option<String>,
|
||||
}
|
||||
|
||||
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<OsString>,
|
||||
vllm_api_key: Option<OsString>,
|
||||
vllm_base_url: Option<OsString>,
|
||||
ollama_api_key: Option<OsString>,
|
||||
ollama_base_url: Option<OsString>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -435,6 +435,7 @@ pub fn env_for(name: &str) -> Option<String> {
|
||||
"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() {
|
||||
|
||||
@@ -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<_>>(),
|
||||
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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
+192
-10
@@ -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<String> {
|
||||
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<PathBuf>
|
||||
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<PathBuf>
|
||||
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<OsString>,
|
||||
vllm_base_url: Option<OsString>,
|
||||
vllm_model: Option<OsString>,
|
||||
ollama_api_key: Option<OsString>,
|
||||
ollama_base_url: Option<OsString>,
|
||||
ollama_model: Option<OsString>,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<tokio::sync::Mutex<()>> =
|
||||
LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
static API_KEY_ENV_LOCK: LazyLock<std::sync::Mutex<()>> =
|
||||
LazyLock::new(|| std::sync::Mutex::new(()));
|
||||
|
||||
struct ScopedCapacityMemoryDir {
|
||||
previous: Option<OsString>,
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+17
-8
@@ -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 <name>`. The facade
|
||||
For hosted or self-hosted providers, set `provider = "nvidia-nim"`,
|
||||
`"fireworks"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass `deepseek --provider <name>`. 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.
|
||||
|
||||
Reference in New Issue
Block a user