From a3aa4bce6f7a9a4d1b4d3e8bdb78edea75042a73 Mon Sep 17 00:00:00 2001 From: Valerio Date: Wed, 6 May 2026 11:36:53 +0200 Subject: [PATCH] fix: support LLM provider compatibility options Closes #36 --- CHANGELOG.md | 1 + README.md | 3 + crates/webclaw-cli/src/main.rs | 5 +- crates/webclaw-llm/src/chain.rs | 2 +- crates/webclaw-llm/src/providers/anthropic.rs | 61 +++++++- crates/webclaw-llm/src/providers/openai.rs | 137 ++++++++++++++++-- 6 files changed, 193 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d163f..8e30acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ### Added - GitHub Releases now include a Windows x86_64 `.zip` with `webclaw.exe`, `webclaw-mcp.exe`, and `webclaw-server.exe`. Thanks to Suryansh Mishra (`@notrealsuryansh`) for the contribution. +- 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. ### Fixed - Improved brand extraction results for modern sites with large app shells. Brand colors, fonts, and logos are now less likely to be polluted by login widgets, customer-logo grids, icon fonts, or generated CSS noise. diff --git a/README.md b/README.md index 4362d35..79758f0 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,10 @@ 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 e97f15d..a45bce8 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 or OpenAI-compatible) + /// Override the LLM base URL (Ollama, OpenAI-compatible, or Anthropic-compatible) #[arg(long, env = "WEBCLAW_LLM_BASE_URL")] llm_base_url: Option, @@ -1919,8 +1919,9 @@ async fn build_llm_provider(cli: &Cli) -> Result, String> { Ok(Box::new(provider)) } "anthropic" => { - let provider = webclaw_llm::providers::anthropic::AnthropicProvider::new( + let provider = webclaw_llm::providers::anthropic::AnthropicProvider::with_base_url( 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 314bf2a..86b0101 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::new(None, None) { + if let Some(anthropic) = AnthropicProvider::with_base_url(None, 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 71ca1f9..e6e43c8 100644 --- a/crates/webclaw-llm/src/providers/anthropic.rs +++ b/crates/webclaw-llm/src/providers/anthropic.rs @@ -10,23 +10,38 @@ use crate::provider::{CompletionRequest, LlmProvider}; use super::load_api_key; -const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; +const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1"; 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()), }) } @@ -34,6 +49,14 @@ 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] @@ -74,7 +97,7 @@ impl LlmProvider for AnthropicProvider { let resp = self .client - .post(ANTHROPIC_API_URL) + .post(self.messages_url()) .header("x-api-key", &self.key) .header("anthropic-version", ANTHROPIC_VERSION) .header("content-type", "application/json") @@ -135,6 +158,11 @@ 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] @@ -151,6 +179,35 @@ 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 6422cc4..3780d8f 100644 --- a/crates/webclaw-llm/src/providers/openai.rs +++ b/crates/webclaw-llm/src/providers/openai.rs @@ -13,6 +13,50 @@ 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 { @@ -31,23 +75,15 @@ 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 } -} - -#[async_trait] -impl LlmProvider for OpenAiProvider { - async fn complete(&self, request: &CompletionRequest) -> Result { - let model = if request.model.is_empty() { - &self.default_model - } else { - &request.model - }; + fn request_body(&self, request: &CompletionRequest, model: &str) -> serde_json::Value { let messages: Vec = request .messages .iter() @@ -60,7 +96,7 @@ impl LlmProvider for OpenAiProvider { }); if request.json_mode { - body["response_format"] = json!({ "type": "json_object" }); + body["response_format"] = self.response_format.as_response_format(); } if let Some(temp) = request.temperature { body["temperature"] = json!(temp); @@ -69,6 +105,21 @@ impl LlmProvider for OpenAiProvider { body["max_tokens"] = json!(max); } + body + } +} + +#[async_trait] +impl LlmProvider for OpenAiProvider { + async fn complete(&self, request: &CompletionRequest) -> Result { + let model = if request.model.is_empty() { + &self.default_model + } else { + &request.model + }; + + let body = self.request_body(request, model); + let url = format!("{}/chat/completions", self.base_url); let resp = self .client @@ -136,6 +187,7 @@ 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] @@ -161,6 +213,69 @@ 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