feat: add anthropic-custom: prefix for Anthropic-compatible endpoints

Add support for custom Anthropic-compatible API endpoints via anthropic-custom: prefix
This commit is contained in:
Argenis 2026-02-15 10:13:18 -05:00 committed by GitHub
parent 8694c2e2d2
commit f8aef8bd62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 77 additions and 2 deletions

View file

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct AnthropicProvider {
credential: Option<String>,
base_url: String,
client: Client,
}
@ -36,11 +37,20 @@ struct ContentBlock {
impl AnthropicProvider {
pub fn new(api_key: Option<&str>) -> Self {
Self::with_base_url(api_key, None)
}
pub fn with_base_url(api_key: Option<&str>, base_url: Option<&str>) -> Self {
let base_url = base_url
.map(|u| u.trim_end_matches('/'))
.unwrap_or("https://api.anthropic.com")
.to_string();
Self {
credential: api_key
.map(str::trim)
.filter(|k| !k.is_empty())
.map(ToString::to_string),
base_url,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
@ -82,7 +92,7 @@ impl Provider for AnthropicProvider {
let mut request = self
.client
.post("https://api.anthropic.com/v1/messages")
.post(format!("{}/v1/messages", self.base_url))
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&request);
@ -119,12 +129,14 @@ mod tests {
let p = AnthropicProvider::new(Some("sk-ant-test123"));
assert!(p.credential.is_some());
assert_eq!(p.credential.as_deref(), Some("sk-ant-test123"));
assert_eq!(p.base_url, "https://api.anthropic.com");
}
#[test]
fn creates_without_key() {
let p = AnthropicProvider::new(None);
assert!(p.credential.is_none());
assert_eq!(p.base_url, "https://api.anthropic.com");
}
#[test]
@ -140,6 +152,25 @@ mod tests {
assert_eq!(p.credential.as_deref(), Some("sk-ant-test123"));
}
#[test]
fn creates_with_custom_base_url() {
let p = AnthropicProvider::with_base_url(Some("sk-ant-test"), Some("https://api.example.com"));
assert_eq!(p.base_url, "https://api.example.com");
assert_eq!(p.credential.as_deref(), Some("sk-ant-test"));
}
#[test]
fn custom_base_url_trims_trailing_slash() {
let p = AnthropicProvider::with_base_url(None, Some("https://api.example.com/"));
assert_eq!(p.base_url, "https://api.example.com");
}
#[test]
fn default_base_url_when_none_provided() {
let p = AnthropicProvider::with_base_url(None, None);
assert_eq!(p.base_url, "https://api.anthropic.com");
}
#[tokio::test]
async fn chat_fails_without_key() {
let p = AnthropicProvider::new(None);

View file

@ -251,9 +251,22 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
)))
}
// ── Anthropic-compatible custom endpoints ───────────
// Format: "anthropic-custom:https://your-api.com"
name if name.starts_with("anthropic-custom:") => {
let base_url = name.strip_prefix("anthropic-custom:").unwrap_or("");
if base_url.is_empty() {
anyhow::bail!("Anthropic-custom provider requires a URL. Format: anthropic-custom:https://your-api.com");
}
Ok(Box::new(anthropic::AnthropicProvider::with_base_url(
api_key, Some(base_url),
)))
}
_ => anyhow::bail!(
"Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\
Tip: Use \"custom:https://your-api.com\" for any OpenAI-compatible endpoint."
Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\
Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints."
),
}
}
@ -489,6 +502,37 @@ mod tests {
}
}
// ── Anthropic-compatible custom endpoints ─────────────────
#[test]
fn factory_anthropic_custom_url() {
let p = create_provider("anthropic-custom:https://api.example.com", Some("key"));
assert!(p.is_ok());
}
#[test]
fn factory_anthropic_custom_trailing_slash() {
let p = create_provider("anthropic-custom:https://api.example.com/", Some("key"));
assert!(p.is_ok());
}
#[test]
fn factory_anthropic_custom_no_key() {
let p = create_provider("anthropic-custom:https://api.example.com", None);
assert!(p.is_ok());
}
#[test]
fn factory_anthropic_custom_empty_url_errors() {
match create_provider("anthropic-custom:", None) {
Err(e) => assert!(
e.to_string().contains("requires a URL"),
"Expected 'requires a URL', got: {e}"
),
Ok(_) => panic!("Expected error for empty anthropic-custom URL"),
}
}
// ── Error cases ──────────────────────────────────────────
#[test]