diff --git a/crates/webclaw-cli/src/main.rs b/crates/webclaw-cli/src/main.rs index e3434c5..3076e5e 100644 --- a/crates/webclaw-cli/src/main.rs +++ b/crates/webclaw-cli/src/main.rs @@ -338,7 +338,7 @@ struct Cli { #[arg(long, num_args = 0..=1, default_missing_value = "3")] summarize: Option, - /// Force a specific LLM provider (ollama, openai, anthropic) + /// Force a specific LLM provider (ollama, openai, atlascloud, anthropic) #[arg(long, env = "WEBCLAW_LLM_PROVIDER")] llm_provider: Option, @@ -2239,6 +2239,15 @@ async fn build_llm_provider(cli: &Cli) -> Result, String> { .ok_or("OPENAI_API_KEY not set")?; Ok(Box::new(provider)) } + "atlascloud" => { + let provider = webclaw_llm::providers::atlascloud::AtlasCloudProvider::new( + None, + cli.llm_base_url.clone(), + cli.llm_model.clone(), + ) + .ok_or("ATLASCLOUD_API_KEY not set")?; + Ok(Box::new(provider)) + } "anthropic" => { let provider = webclaw_llm::providers::anthropic::AnthropicProvider::with_base_url( None, @@ -2249,14 +2258,14 @@ async fn build_llm_provider(cli: &Cli) -> Result, String> { Ok(Box::new(provider)) } other => Err(format!( - "unknown LLM provider: {other} (use ollama, openai, or anthropic)" + "unknown LLM provider: {other} (use ollama, openai, atlascloud, or anthropic)" )), } } else { let chain = webclaw_llm::ProviderChain::default().await; if chain.is_empty() { return Err( - "no LLM providers available -- start Ollama or set OPENAI_API_KEY / ANTHROPIC_API_KEY" + "no LLM providers available -- start Ollama or set OPENAI_API_KEY / ATLASCLOUD_API_KEY / ANTHROPIC_API_KEY" .into(), ); } diff --git a/crates/webclaw-llm/src/chain.rs b/crates/webclaw-llm/src/chain.rs index e2c6b8b..4b6e9db 100644 --- a/crates/webclaw-llm/src/chain.rs +++ b/crates/webclaw-llm/src/chain.rs @@ -1,5 +1,5 @@ /// Provider chain — tries providers in order until one succeeds. -/// Default order: Ollama (local, free) -> OpenAI -> Gemini -> Anthropic. +/// Default order: Ollama (local, free) -> OpenAI -> Atlas Cloud -> Gemini -> Anthropic. /// Only includes providers that are actually configured/available. use async_trait::async_trait; use tracing::{debug, warn}; @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use crate::error::LlmError; use crate::provider::{CompletionRequest, LlmProvider}; use crate::providers::{ - anthropic::AnthropicProvider, gemini::GeminiProvider, ollama::OllamaProvider, - openai::OpenAiProvider, + anthropic::AnthropicProvider, atlascloud::AtlasCloudProvider, gemini::GeminiProvider, + ollama::OllamaProvider, openai::OpenAiProvider, }; pub struct ProviderChain { @@ -16,7 +16,7 @@ pub struct ProviderChain { } impl ProviderChain { - /// Build the default chain: Ollama -> OpenAI -> Gemini -> Anthropic. + /// Build the default chain: Ollama -> OpenAI -> Atlas Cloud -> Gemini -> Anthropic. /// Ollama is always added (availability checked at call time). /// Cloud providers are only added if their API keys are configured. /// Gemini sits ahead of Anthropic so Google Cloud credits are preferred, @@ -37,6 +37,11 @@ impl ProviderChain { providers.push(Box::new(openai)); } + if let Some(atlascloud) = AtlasCloudProvider::new(None, None, None) { + debug!("atlascloud configured, adding to chain"); + providers.push(Box::new(atlascloud)); + } + if let Some(gemini) = GeminiProvider::new(None, None, None) { debug!("gemini configured, adding to chain"); providers.push(Box::new(gemini)); diff --git a/crates/webclaw-llm/src/providers/atlascloud.rs b/crates/webclaw-llm/src/providers/atlascloud.rs new file mode 100644 index 0000000..a234ec9 --- /dev/null +++ b/crates/webclaw-llm/src/providers/atlascloud.rs @@ -0,0 +1,76 @@ +/// Atlas Cloud provider — OpenAI-compatible chat completions with Atlas defaults. +use async_trait::async_trait; + +use crate::error::LlmError; +use crate::provider::{CompletionRequest, LlmProvider}; + +use super::openai::OpenAiProvider; + +pub struct AtlasCloudProvider { + inner: OpenAiProvider, +} + +impl AtlasCloudProvider { + /// Returns `None` if no Atlas Cloud API key is available (param or env). + pub fn new( + key_override: Option, + base_url: Option, + model: Option, + ) -> Option { + let key = super::load_api_key(key_override, "ATLASCLOUD_API_KEY")?; + let base_url = base_url + .or_else(|| std::env::var("ATLASCLOUD_BASE_URL").ok()) + .unwrap_or_else(|| "https://api.atlascloud.ai/v1".into()); + let model = model.unwrap_or_else(|| "qwen/qwen3.5-flash".into()); + let inner = OpenAiProvider::new(Some(key), Some(base_url), Some(model))?; + Some(Self { inner }) + } + + pub fn default_model(&self) -> &str { + self.inner.default_model() + } +} + +#[async_trait] +impl LlmProvider for AtlasCloudProvider { + async fn complete(&self, request: &CompletionRequest) -> Result { + self.inner.complete(request).await + } + + async fn is_available(&self) -> bool { + self.inner.is_available().await + } + + fn name(&self) -> &str { + "atlascloud" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_key_returns_none() { + assert!(AtlasCloudProvider::new(Some(String::new()), None, None).is_none()); + } + + #[test] + fn explicit_key_constructs_with_atlas_defaults() { + let provider = + AtlasCloudProvider::new(Some("test-key".into()), None, None).expect("should construct"); + assert_eq!(provider.name(), "atlascloud"); + assert_eq!(provider.default_model(), "qwen/qwen3.5-flash"); + } + + #[test] + fn explicit_model_override() { + let provider = AtlasCloudProvider::new( + Some("test-key".into()), + Some("https://proxy.example.com/v1".into()), + Some("deepseek-ai/deepseek-v4-pro".into()), + ) + .expect("should construct"); + assert_eq!(provider.default_model(), "deepseek-ai/deepseek-v4-pro"); + } +} diff --git a/crates/webclaw-llm/src/providers/mod.rs b/crates/webclaw-llm/src/providers/mod.rs index d6ae34a..7a5dd65 100644 --- a/crates/webclaw-llm/src/providers/mod.rs +++ b/crates/webclaw-llm/src/providers/mod.rs @@ -1,4 +1,5 @@ pub mod anthropic; +pub mod atlascloud; pub mod gemini; pub mod ollama; pub mod openai;