Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions crates/webclaw-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ struct Cli {
#[arg(long, num_args = 0..=1, default_missing_value = "3")]
summarize: Option<usize>,

/// 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<String>,

Expand Down Expand Up @@ -2239,6 +2239,15 @@ async fn build_llm_provider(cli: &Cli) -> Result<Box<dyn LlmProvider>, 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,
Expand All @@ -2249,14 +2258,14 @@ async fn build_llm_provider(cli: &Cli) -> Result<Box<dyn LlmProvider>, 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(),
);
}
Expand Down
13 changes: 9 additions & 4 deletions crates/webclaw-llm/src/chain.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
/// 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};

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 {
providers: Vec<Box<dyn LlmProvider>>,
}

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,
Expand All @@ -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));
Expand Down
76 changes: 76 additions & 0 deletions crates/webclaw-llm/src/providers/atlascloud.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
base_url: Option<String>,
model: Option<String>,
) -> Option<Self> {
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<String, LlmError> {
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");
}
}
1 change: 1 addition & 0 deletions crates/webclaw-llm/src/providers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod anthropic;
pub mod atlascloud;
pub mod gemini;
pub mod ollama;
pub mod openai;
Expand Down