diff --git a/USAGE.md b/USAGE.md index cc15da2a9a..18a97586bd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -225,6 +225,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token" | `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) | | OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens | | 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) | +| Ollama local instance | `OLLAMA_HOST` | no auth header (Ollama requires none) | local Ollama server at `http://127.0.0.1:11434` | **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. diff --git a/docs/local-openai-compatible-providers.md b/docs/local-openai-compatible-providers.md index a0b22a5215..189ea7f725 100644 --- a/docs/local-openai-compatible-providers.md +++ b/docs/local-openai-compatible-providers.md @@ -56,6 +56,13 @@ ollama serve In another shell: +```bash +export OLLAMA_HOST="http://127.0.0.1:11434" +claw --model "qwen2.5-coder:7b" prompt "say hello" +``` + +`OLLAMA_HOST` is now the preferred env var for Ollama. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported for existing setups that already rely on the OpenAI-compatible path. + ```bash export OPENAI_BASE_URL="http://127.0.0.1:11434/v1" export OPENAI_API_KEY="local-dev-token" diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 6e68fd2e2c..176a62d445 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 237e979969..c0a68a258b 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 d5291b8eae..d413cb7912 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 5febf8417a..e3373f5803 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()); + } }