diff --git a/CHANGELOG.md b/CHANGELOG.md index 7858ae4..63d163f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,6 @@ All notable changes to webclaw are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/). -## [0.5.9] — 2026-05-06 - -### Fixed -- LLM providers now support `ANTHROPIC_BASE_URL` for Anthropic-compatible proxies, plus an `OPENAI_RESPONSE_FORMAT_TYPE` override for OpenAI-compatible backends such as LM Studio. Thanks to Toti (`@Toti330`) for the report. - ---- - ## [0.5.8] — 2026-05-04 ### Added diff --git a/Cargo.lock b/Cargo.lock index e49ccc3..4a6b90e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3219,7 +3219,7 @@ dependencies = [ [[package]] name = "webclaw-cli" -version = "0.5.9" +version = "0.5.8" dependencies = [ "clap", "dotenvy", @@ -3240,7 +3240,7 @@ dependencies = [ [[package]] name = "webclaw-core" -version = "0.5.9" +version = "0.5.8" dependencies = [ "ego-tree", "once_cell", @@ -3258,7 +3258,7 @@ dependencies = [ [[package]] name = "webclaw-fetch" -version = "0.5.9" +version = "0.5.8" dependencies = [ "async-trait", "bytes", @@ -3284,7 +3284,7 @@ dependencies = [ [[package]] name = "webclaw-llm" -version = "0.5.9" +version = "0.5.8" dependencies = [ "async-trait", "reqwest", @@ -3297,7 +3297,7 @@ dependencies = [ [[package]] name = "webclaw-mcp" -version = "0.5.9" +version = "0.5.8" dependencies = [ "dirs", "dotenvy", @@ -3317,7 +3317,7 @@ dependencies = [ [[package]] name = "webclaw-pdf" -version = "0.5.9" +version = "0.5.8" dependencies = [ "pdf-extract", "thiserror", @@ -3326,7 +3326,7 @@ dependencies = [ [[package]] name = "webclaw-server" -version = "0.5.9" +version = "0.5.8" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 12a4b73..f77595d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.5.9" +version = "0.5.8" edition = "2024" license = "AGPL-3.0" repository = "https://github.com/0xMassi/webclaw" diff --git a/README.md b/README.md index 79758f0..4362d35 100644 --- a/README.md +++ b/README.md @@ -358,10 +358,7 @@ webclaw/ | `WEBCLAW_API_KEY` | Cloud API key (enables bot bypass, JS rendering, search, research) | | `OLLAMA_HOST` | Ollama URL for local LLM features (default: `http://localhost:11434`) | | `OPENAI_API_KEY` | OpenAI API key for LLM features | -| `OPENAI_BASE_URL` | OpenAI-compatible base URL (default: `https://api.openai.com/v1`) | -| `OPENAI_RESPONSE_FORMAT_TYPE` | JSON-mode response format for OpenAI-compatible backends: `json_object` (default), `json_schema`, or `text`. Use `text` or `json_schema` for LM Studio. | | `ANTHROPIC_API_KEY` | Anthropic API key for LLM features | -| `ANTHROPIC_BASE_URL` | Anthropic-compatible base URL (default: `https://api.anthropic.com/v1`) | | `WEBCLAW_PROXY` | Single proxy URL | | `WEBCLAW_PROXY_FILE` | Path to proxy pool file | diff --git a/crates/webclaw-cli/src/main.rs b/crates/webclaw-cli/src/main.rs index a45bce8..e97f15d 100644 --- a/crates/webclaw-cli/src/main.rs +++ b/crates/webclaw-cli/src/main.rs @@ -260,7 +260,7 @@ struct Cli { #[arg(long, env = "WEBCLAW_LLM_MODEL")] llm_model: Option, - /// Override the LLM base URL (Ollama, OpenAI-compatible, or Anthropic-compatible) + /// Override the LLM base URL (Ollama or OpenAI-compatible) #[arg(long, env = "WEBCLAW_LLM_BASE_URL")] llm_base_url: Option, @@ -1919,9 +1919,8 @@ async fn build_llm_provider(cli: &Cli) -> Result, String> { Ok(Box::new(provider)) } "anthropic" => { - let provider = webclaw_llm::providers::anthropic::AnthropicProvider::with_base_url( + let provider = webclaw_llm::providers::anthropic::AnthropicProvider::new( None, - cli.llm_base_url.clone(), cli.llm_model.clone(), ) .ok_or("ANTHROPIC_API_KEY not set")?; diff --git a/crates/webclaw-llm/src/chain.rs b/crates/webclaw-llm/src/chain.rs index 86b0101..314bf2a 100644 --- a/crates/webclaw-llm/src/chain.rs +++ b/crates/webclaw-llm/src/chain.rs @@ -34,7 +34,7 @@ impl ProviderChain { providers.push(Box::new(openai)); } - if let Some(anthropic) = AnthropicProvider::with_base_url(None, None, None) { + if let Some(anthropic) = AnthropicProvider::new(None, None) { debug!("anthropic configured, adding to chain"); providers.push(Box::new(anthropic)); } diff --git a/crates/webclaw-llm/src/providers/anthropic.rs b/crates/webclaw-llm/src/providers/anthropic.rs index e6e43c8..71ca1f9 100644 --- a/crates/webclaw-llm/src/providers/anthropic.rs +++ b/crates/webclaw-llm/src/providers/anthropic.rs @@ -10,38 +10,23 @@ use crate::provider::{CompletionRequest, LlmProvider}; use super::load_api_key; -const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1"; +const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; pub struct AnthropicProvider { client: reqwest::Client, key: String, - base_url: String, default_model: String, } impl AnthropicProvider { /// Returns `None` if no API key is available (param or env). pub fn new(key_override: Option, model: Option) -> Option { - Self::with_base_url(key_override, None, model) - } - - /// Returns `None` if no API key is available (param or env). - pub fn with_base_url( - key_override: Option, - base_url: Option, - model: Option, - ) -> Option { let key = load_api_key(key_override, "ANTHROPIC_API_KEY")?; Some(Self { client: reqwest::Client::new(), key, - base_url: base_url - .or_else(|| std::env::var("ANTHROPIC_BASE_URL").ok()) - .unwrap_or_else(|| DEFAULT_ANTHROPIC_BASE_URL.into()) - .trim_end_matches('/') - .to_string(), default_model: model.unwrap_or_else(|| "claude-sonnet-4-20250514".into()), }) } @@ -49,14 +34,6 @@ impl AnthropicProvider { pub fn default_model(&self) -> &str { &self.default_model } - - fn messages_url(&self) -> String { - if self.base_url.ends_with("/messages") { - self.base_url.clone() - } else { - format!("{}/messages", self.base_url) - } - } } #[async_trait] @@ -97,7 +74,7 @@ impl LlmProvider for AnthropicProvider { let resp = self .client - .post(self.messages_url()) + .post(ANTHROPIC_API_URL) .header("x-api-key", &self.key) .header("anthropic-version", ANTHROPIC_VERSION) .header("content-type", "application/json") @@ -158,11 +135,6 @@ mod tests { assert_eq!(provider.name(), "anthropic"); assert_eq!(provider.default_model, "claude-sonnet-4-20250514"); assert_eq!(provider.key, "sk-ant-test"); - assert_eq!(provider.base_url, "https://api.anthropic.com/v1"); - assert_eq!( - provider.messages_url(), - "https://api.anthropic.com/v1/messages" - ); } #[test] @@ -179,35 +151,6 @@ mod tests { assert_eq!(provider.default_model(), "claude-sonnet-4-20250514"); } - #[test] - fn custom_base_url_appends_messages_path() { - let provider = AnthropicProvider::with_base_url( - Some("sk-ant-test".into()), - Some("https://proxy.example.test/anthropic/v1/".into()), - None, - ) - .unwrap(); - assert_eq!(provider.base_url, "https://proxy.example.test/anthropic/v1"); - assert_eq!( - provider.messages_url(), - "https://proxy.example.test/anthropic/v1/messages" - ); - } - - #[test] - fn custom_full_messages_url_is_not_doubled() { - let provider = AnthropicProvider::with_base_url( - Some("sk-ant-test".into()), - Some("https://proxy.example.test/v1/messages".into()), - None, - ) - .unwrap(); - assert_eq!( - provider.messages_url(), - "https://proxy.example.test/v1/messages" - ); - } - // Env var fallback tests mutate process-global state and race with parallel tests. // The code path is trivial (load_api_key -> env::var().ok()). Run in isolation if needed: // cargo test -p webclaw-llm env_var -- --ignored --test-threads=1 diff --git a/crates/webclaw-llm/src/providers/openai.rs b/crates/webclaw-llm/src/providers/openai.rs index 3780d8f..6422cc4 100644 --- a/crates/webclaw-llm/src/providers/openai.rs +++ b/crates/webclaw-llm/src/providers/openai.rs @@ -13,50 +13,6 @@ pub struct OpenAiProvider { key: String, base_url: String, default_model: String, - response_format: OpenAiResponseFormat, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OpenAiResponseFormat { - JsonObject, - JsonSchema, - Text, -} - -impl OpenAiResponseFormat { - fn from_env() -> Self { - std::env::var("OPENAI_RESPONSE_FORMAT_TYPE") - .ok() - .and_then(|value| Self::parse(&value)) - .unwrap_or(Self::JsonObject) - } - - fn parse(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "" | "json_object" => Some(Self::JsonObject), - "json_schema" => Some(Self::JsonSchema), - "text" => Some(Self::Text), - _ => None, - } - } - - fn as_response_format(self) -> serde_json::Value { - match self { - Self::JsonObject => json!({ "type": "json_object" }), - Self::JsonSchema => json!({ - "type": "json_schema", - "json_schema": { - "name": "webclaw_response", - "schema": { - "type": "object", - "additionalProperties": true - }, - "strict": false - } - }), - Self::Text => json!({ "type": "text" }), - } - } } impl OpenAiProvider { @@ -75,38 +31,12 @@ impl OpenAiProvider { .or_else(|| std::env::var("OPENAI_BASE_URL").ok()) .unwrap_or_else(|| "https://api.openai.com/v1".into()), default_model: model.unwrap_or_else(|| "gpt-4o-mini".into()), - response_format: OpenAiResponseFormat::from_env(), }) } pub fn default_model(&self) -> &str { &self.default_model } - - fn request_body(&self, request: &CompletionRequest, model: &str) -> serde_json::Value { - let messages: Vec = request - .messages - .iter() - .map(|m| json!({ "role": m.role, "content": m.content })) - .collect(); - - let mut body = json!({ - "model": model, - "messages": messages, - }); - - if request.json_mode { - body["response_format"] = self.response_format.as_response_format(); - } - if let Some(temp) = request.temperature { - body["temperature"] = json!(temp); - } - if let Some(max) = request.max_tokens { - body["max_tokens"] = json!(max); - } - - body - } } #[async_trait] @@ -118,7 +48,26 @@ impl LlmProvider for OpenAiProvider { &request.model }; - let body = self.request_body(request, model); + let messages: Vec = request + .messages + .iter() + .map(|m| json!({ "role": m.role, "content": m.content })) + .collect(); + + let mut body = json!({ + "model": model, + "messages": messages, + }); + + if request.json_mode { + body["response_format"] = json!({ "type": "json_object" }); + } + if let Some(temp) = request.temperature { + body["temperature"] = json!(temp); + } + if let Some(max) = request.max_tokens { + body["max_tokens"] = json!(max); + } let url = format!("{}/chat/completions", self.base_url); let resp = self @@ -187,7 +136,6 @@ mod tests { assert_eq!(provider.default_model, "gpt-4o-mini"); assert_eq!(provider.base_url, "https://api.openai.com/v1"); assert_eq!(provider.key, "test-key-123"); - assert_eq!(provider.response_format, OpenAiResponseFormat::JsonObject); } #[test] @@ -213,69 +161,6 @@ mod tests { assert_eq!(provider.default_model(), "gpt-4o-mini"); } - #[test] - fn json_mode_defaults_to_openai_json_object() { - let provider = OpenAiProvider::new( - Some("test-key".into()), - Some("https://api.openai.com/v1".into()), - None, - ) - .unwrap(); - let req = CompletionRequest { - model: String::new(), - messages: vec![], - temperature: None, - max_tokens: None, - json_mode: true, - }; - let body = provider.request_body(&req, provider.default_model()); - assert_eq!(body["response_format"], json!({ "type": "json_object" })); - } - - #[test] - fn json_schema_response_format_for_compatible_backends() { - let mut provider = OpenAiProvider::new( - Some("test-key".into()), - Some("http://localhost:1234/v1".into()), - Some("local-model".into()), - ) - .unwrap(); - provider.response_format = OpenAiResponseFormat::JsonSchema; - let req = CompletionRequest { - model: String::new(), - messages: vec![], - temperature: None, - max_tokens: None, - json_mode: true, - }; - let body = provider.request_body(&req, provider.default_model()); - assert_eq!(body["response_format"]["type"], "json_schema"); - assert_eq!( - body["response_format"]["json_schema"]["schema"]["type"], - "object" - ); - } - - #[test] - fn text_response_format_for_lm_studio() { - let mut provider = OpenAiProvider::new( - Some("test-key".into()), - Some("http://localhost:1234/v1".into()), - Some("local-model".into()), - ) - .unwrap(); - provider.response_format = OpenAiResponseFormat::Text; - let req = CompletionRequest { - model: String::new(), - messages: vec![], - temperature: None, - max_tokens: None, - json_mode: true, - }; - let body = provider.request_body(&req, provider.default_model()); - assert_eq!(body["response_format"], json!({ "type": "text" })); - } - // Env var fallback tests mutate process-global state and race with parallel tests. // The code path is trivial (load_api_key -> env::var().ok()). Run in isolation if needed: // cargo test -p webclaw-llm env_var -- --ignored --test-threads=1