Skip to content

Commit be8112f

Browse files
bellmanbellman
authored andcommitted
feat: add native Ollama provider support via OLLAMA_HOST env var
- OLLAMA_HOST takes priority over OPENAI_BASE_URL for local Ollama instances - No API key required; placeholder token used for Authorization header - Model names like 'qwen3:8b' bypass strict provider/model syntax validation - detect_provider_kind() checks OLLAMA_HOST first in routing cascade - ProviderClient dispatch uses from_ollama_env() when OLLAMA_HOST is set - Updated USAGE.md and docs with OLLAMA_HOST as preferred env var - Added OLLAMA_CONFIG constant and from_ollama_env() to openai_compat - Added test_ollama_host_bypasses_model_validation unit test - Supersedes PR #3213 (which had a duplicate if-let bug in mod.rs)
1 parent 503d515 commit be8112f

6 files changed

Lines changed: 77 additions & 17 deletions

File tree

USAGE.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
245245
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
246246
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
247247
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
248+
| Ollama local instance | `OLLAMA_HOST` | no auth header (Ollama requires none) | local Ollama server at `http://127.0.0.1:11434` |
248249

249250
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
250251

@@ -305,18 +306,18 @@ cd rust
305306
### Ollama
306307

307308
```bash
308-
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
309-
unset OPENAI_API_KEY
309+
export OLLAMA_HOST="http://127.0.0.1:11434"
310310

311311
cd rust
312312
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
313313
```
314314

315-
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), `OPENAI_BASE_URL` selects the local OpenAI-compatible route even when `OPENAI_API_KEY` is unset:
315+
`OLLAMA_HOST` is the preferred env var. Claw routes all models to the local Ollama endpoint automatically, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported.
316+
317+
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), both approaches work:
316318

317319
```bash
318-
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
319-
unset OPENAI_API_KEY
320+
export OLLAMA_HOST="http://127.0.0.1:11434"
320321

321322
cd rust
322323
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"

docs/local-openai-compatible-providers.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ ollama serve
5757
In another shell:
5858

5959
```bash
60-
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
61-
unset OPENAI_API_KEY
60+
export OLLAMA_HOST="http://127.0.0.1:11434"
6261
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
6362
```
6463

64+
`OLLAMA_HOST` is the preferred env var for Ollama. Claw routes all models to the local OpenAI-compatible endpoint automatically when this is set, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported for existing setups.
65+
6566
If Ollama is running without auth, `unset OPENAI_API_KEY` is acceptable. Use a placeholder token rather than a real cloud API key if your local server requires an Authorization header.
6667

6768
## llama.cpp server

rust/crates/api/src/client.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,25 @@ impl ProviderClient {
3232
OpenAiCompatConfig::xai(),
3333
)?)),
3434
ProviderKind::OpenAi => {
35-
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
36-
// speak the OpenAI wire format, but they need the DashScope config which
37-
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
38-
let config = match providers::metadata_for_model(&resolved_model) {
39-
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
40-
OpenAiCompatConfig::dashscope()
41-
}
42-
_ => OpenAiCompatConfig::openai(),
43-
};
44-
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
35+
// OLLAMA_HOST takes priority: local Ollama needs no API key
36+
// and ignores DashScope/OpenAI env-based dispatch.
37+
if std::env::var_os("OLLAMA_HOST").is_some() {
38+
Ok(Self::OpenAi(
39+
openai_compat::OpenAiCompatClient::from_ollama_env()
40+
.expect("from_ollama_env always returns Some"),
41+
))
42+
} else {
43+
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
44+
// speak the OpenAI wire format, but they need the DashScope config which
45+
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
46+
let config = match providers::metadata_for_model(&resolved_model) {
47+
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
48+
OpenAiCompatConfig::dashscope()
49+
}
50+
_ => OpenAiCompatConfig::openai(),
51+
};
52+
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
53+
}
4554
}
4655
}
4756
}

rust/crates/api/src/providers/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ fn looks_like_local_openai_model(model: &str) -> bool {
351351

352352
#[must_use]
353353
pub fn detect_provider_kind(model: &str) -> ProviderKind {
354+
// OLLAMA_HOST takes priority: if set, route all models through the local
355+
// OpenAI-compatible endpoint regardless of model name or other env vars.
356+
if std::env::var_os("OLLAMA_HOST").is_some() {
357+
return ProviderKind::OpenAi;
358+
}
354359
let resolved_model = resolve_model_alias(model);
355360
if let Some(metadata) = metadata_for_model(&resolved_model) {
356361
return metadata.provider;

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
4949
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
5050
const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood)
5151

52+
pub const OLLAMA_CONFIG: OpenAiCompatConfig = OpenAiCompatConfig {
53+
provider_name: "Ollama",
54+
api_key_env: "OLLAMA_HOST",
55+
base_url_env: "OLLAMA_HOST",
56+
default_base_url: "http://127.0.0.1:11434/v1",
57+
max_request_body_bytes: 104_857_600,
58+
};
59+
5260
impl OpenAiCompatConfig {
5361
#[must_use]
5462
pub const fn xai() -> Self {
@@ -149,6 +157,22 @@ impl OpenAiCompatClient {
149157
};
150158
Ok(Self::new(api_key, config).with_base_url(base_url))
151159
}
160+
/// Create an Ollama client from `OLLAMA_HOST` env var.
161+
/// Ollama requires no API key; a placeholder is used for the Authorization header.
162+
pub fn from_ollama_env() -> Option<Self> {
163+
let host =
164+
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string());
165+
let base_url = format!("{}/v1", host.trim_end_matches('/'));
166+
Some(Self {
167+
http: build_http_client_or_default(),
168+
api_key: "ollama".to_string(),
169+
config: OLLAMA_CONFIG,
170+
base_url,
171+
max_retries: DEFAULT_MAX_RETRIES,
172+
initial_backoff: DEFAULT_INITIAL_BACKOFF,
173+
max_backoff: DEFAULT_MAX_BACKOFF,
174+
})
175+
}
152176

153177
#[must_use]
154178
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2903,6 +2903,14 @@ fn resolve_model_alias_with_config(model: &str) -> String {
29032903
/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars.
29042904
fn validate_model_syntax(model: &str) -> Result<(), String> {
29052905
let trimmed = model.trim();
2906+
// Ollama models use names like "qwen3:8b" that don't match provider/model
2907+
// syntax. Skip strict validation when OLLAMA_HOST is configured.
2908+
if std::env::var_os("OLLAMA_HOST").is_some() {
2909+
if trimmed.is_empty() {
2910+
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <model-name> e.g. --model qwen3:8b".to_string());
2911+
}
2912+
return Ok(());
2913+
}
29062914
if trimmed.is_empty() {
29072915
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-7".to_string());
29082916
}
@@ -19689,4 +19697,16 @@ mod alias_resolution_tests {
1968919697
assert_eq!(resolve_model_alias_with_config(model), model);
1969019698
assert!(validate_model_syntax(model).is_ok());
1969119699
}
19700+
#[test]
19701+
fn test_ollama_host_bypasses_model_validation() {
19702+
// Safety: test sets and clears env var within the test.
19703+
std::env::set_var("OLLAMA_HOST", "http://127.0.0.1:11434");
19704+
// Ollama model names with colons pass
19705+
assert!(validate_model_syntax("qwen3:8b").is_ok());
19706+
assert!(validate_model_syntax("gemma4:e2b").is_ok());
19707+
assert!(validate_model_syntax("qwen3.6:27b-nvfp4").is_ok());
19708+
// Empty model still rejected
19709+
assert!(validate_model_syntax("").is_err());
19710+
std::env::remove_var("OLLAMA_HOST");
19711+
}
1969219712
}

0 commit comments

Comments
 (0)