From 943e7b176265e69700b76631892c7219348793d7 Mon Sep 17 00:00:00 2001 From: Aneesh Date: Tue, 2 Jun 2026 16:03:20 -0400 Subject: [PATCH] feat: first class Ollama provider via OLLAMA_HOST --- rust/crates/api/src/client.rs | 27 ++++++++++++------- rust/crates/api/src/providers/mod.rs | 4 +++ .../crates/api/src/providers/openai_compat.rs | 23 ++++++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 17 ++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 6e68fd2e..176a62d4 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -32,16 +32,23 @@ impl ProviderClient { OpenAiCompatConfig::xai(), )?)), ProviderKind::OpenAi => { - // DashScope models (qwen-*) also return ProviderKind::OpenAi because they - // speak the OpenAI wire format, but they need the DashScope config which - // reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com. - let config = match providers::metadata_for_model(&resolved_model) { - Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => { - OpenAiCompatConfig::dashscope() - } - _ => OpenAiCompatConfig::openai(), - }; - Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?)) + if std::env::var_os("OLLAMA_HOST").is_some() { + // unwrap is safe: from_ollama_env always returns Some + Ok(Self::OpenAi( + openai_compat::OpenAiCompatClient::from_ollama_env().unwrap(), + )) + } else { + // DashScope models (qwen-*) also return ProviderKind::OpenAi because they + // speak the OpenAI wire format, but they need the DashScope config which + // reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com. + let config = match providers::metadata_for_model(&resolved_model) { + Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => { + OpenAiCompatConfig::dashscope() + } + _ => OpenAiCompatConfig::openai(), + }; + Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?)) + } } } } diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 237e9799..c0a68a25 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -339,6 +339,10 @@ pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics { #[must_use] pub fn detect_provider_kind(model: &str) -> ProviderKind { + if std::env::var_os("OLLAMA_HOST").is_some() { + return ProviderKind::OpenAi; + } + if let Some(metadata) = metadata_for_model(model) { return metadata.provider; } diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index d5291b8e..d413cb79 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -48,6 +48,14 @@ const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood) +pub const OLLAMA_CONFIG: OpenAiCompatConfig = OpenAiCompatConfig { + provider_name: "Ollama", + api_key_env: "OLLAMA_HOST", + base_url_env: "OLLAMA_HOST", + default_base_url: "http://127.0.0.1:11434/v1", + max_request_body_bytes: 104_857_600, +}; + impl OpenAiCompatConfig { #[must_use] pub const fn xai() -> Self { @@ -140,6 +148,21 @@ impl OpenAiCompatClient { Ok(Self::new(api_key, config)) } + pub fn from_ollama_env() -> Option { + let host = + std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string()); + let base_url = format!("{}/v1", host.trim_end_matches('/')); + Some(Self { + http: build_http_client_or_default(), + api_key: "ollama".to_string(), + config: OLLAMA_CONFIG, + base_url, + max_retries: DEFAULT_MAX_RETRIES, + initial_backoff: DEFAULT_INITIAL_BACKOFF, + max_backoff: DEFAULT_MAX_BACKOFF, + }) + } + #[must_use] pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.base_url = base_url.into(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 5febf841..e3373f58 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1991,6 +1991,12 @@ fn resolve_model_alias_with_config(model: &str) -> String { /// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. fn validate_model_syntax(model: &str) -> Result<(), String> { let trimmed = model.trim(); + if std::env::var_os("OLLAMA_HOST").is_some() { + if trimmed.is_empty() { + return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string()); + } + return Ok(()); + } if trimmed.is_empty() { return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string()); } @@ -16659,4 +16665,15 @@ mod alias_resolution_tests { assert_eq!(resolve_model_alias_with_config(model), model); assert!(validate_model_syntax(model).is_ok()); } + + #[test] + fn test_ollama_host_bypasses_provider_model_validation() { + // Safety: test sets and clears env var within the test. + // May be flaky if tests run in parallel with conflicting OLLAMA_HOST usage, + // but acceptable for a unit-level smoke test. + std::env::set_var("OLLAMA_HOST", "http://127.0.0.1:11434"); + let result = validate_model_syntax("qwen2.5-coder:7b"); + std::env::remove_var("OLLAMA_HOST"); + assert!(result.is_ok()); + } }