diff --git a/.env.example b/.env.example index 18f01ea..4c011b0 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,40 @@ # ClawForge environment configuration (example) # Copy to `.env` and adjust. `.env` is git-ignored. -# ── Runtime (existing ClawForge agent runtime) ─────────────────────────────── -# LLM provider key used by the planner crate. +# ── Runtime: model providers ───────────────────────────────────────────────── +# Set the API key for any provider you want; the runtime registers each one +# whose variable is present. See docs/model-providers.md for the full list. + +# Aggregator (one key, many models). Optional override: OLLAMA_URL for local. OPENROUTER_API_KEY="sk-or-v1-..." +OLLAMA_URL="http://localhost:11434" + +# Global / Western providers +# OPENAI_API_KEY="" +# ANTHROPIC_API_KEY="" +# GEMINI_API_KEY="" +# MISTRAL_API_KEY="" +# XAI_API_KEY="" +# GROQ_API_KEY="" +# TOGETHER_API_KEY="" +# FIREWORKS_API_KEY="" +# PERPLEXITY_API_KEY="" +# COHERE_API_KEY="" + +# Chinese providers +# DEEPSEEK_API_KEY="" # DeepSeek +# DASHSCOPE_API_KEY="" # Alibaba Qwen (DashScope) +# ZHIPU_API_KEY="" # Zhipu AI (GLM) +# MOONSHOT_API_KEY="" # Moonshot AI (Kimi) +# QIANFAN_API_KEY="" # Baidu ERNIE (Qianfan) +# MINIMAX_API_KEY="" # MiniMax +# HUNYUAN_API_KEY="" # Tencent Hunyuan +# YI_API_KEY="" # 01.AI Yi +# STEPFUN_API_KEY="" # StepFun +# BAICHUAN_API_KEY="" # Baichuan AI +# SPARK_API_KEY="" # iFlytek Spark +# SENSENOVA_API_KEY="" # SenseTime SenseNova + # Bind the gateway to Tailscale for secure remote access (1 to enable). CLAWFORGE_ENABLE_TAILSCALE=0 diff --git a/README.md b/README.md index 42f09ed..42f7725 100644 --- a/README.md +++ b/README.md @@ -99,19 +99,25 @@ compliance report — in memory. See [docs/demo.md](docs/demo.md). ### Running the full runtime (optional) ```bash -export OPENROUTER_API_KEY="sk-or-v1-..." +# Set a key for any provider(s) you want; the runtime registers each one present. +export ANTHROPIC_API_KEY="sk-ant-..." # or OPENAI_API_KEY, DEEPSEEK_API_KEY, OPENROUTER_API_KEY, ... cargo run -p clawforge-cli -- serve --port 3000 # local-first gateway cd frontend && npm install && npm run dev # dashboard (separate terminal) # or: docker-compose up --build ``` +ClawForge is multi-provider: OpenAI, Anthropic, Google Gemini, Mistral, xAI, +Groq, OpenRouter, local Ollama, and the major Chinese providers (DeepSeek, Qwen, +Zhipu GLM, Moonshot/Kimi, Baidu ERNIE, MiniMax, Tencent Hunyuan, 01.AI Yi, +StepFun, Baichuan, iFlytek, SenseTime). See [docs/model-providers.md](docs/model-providers.md). + Configuration is environment-driven; see [.env.example](.env.example) and [docs/installation.md](docs/installation.md). ## Documentation -- **Start here:** [Product positioning](docs/product-positioning.md) · [Architecture](docs/architecture.md) · [Diagrams](docs/diagrams.md) · [Installation](docs/installation.md) · [Demo](docs/demo.md) +- **Start here:** [Product positioning](docs/product-positioning.md) · [Architecture](docs/architecture.md) · [Diagrams](docs/diagrams.md) · [Installation](docs/installation.md) · [Model providers](docs/model-providers.md) · [Demo](docs/demo.md) - **Use cases:** [Overview](docs/use-cases.md) · [Government municipality](docs/government-municipality.md) · [Enterprise IT](docs/enterprise.md) - **Operations:** [UAE PDPL note](docs/uae-pdpl.md) · [Security disclaimer](docs/security-disclaimer.md) · [Roadmap](docs/roadmap.md) · [Limitations](docs/limitations.md) - **Contributing:** [Developer guide](docs/developer-guide.md) · [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/backend/cli/Cargo.toml b/backend/cli/Cargo.toml index 0488876..91dab36 100644 --- a/backend/cli/Cargo.toml +++ b/backend/cli/Cargo.toml @@ -25,7 +25,7 @@ uuid = { workspace = true } chrono = { workspace = true } clap = { version = "4", features = ["derive"] } axum = { workspace = true, features = ["ws"] } -tower-http = { version = "0.6", features = ["cors", "trace"] } +tower-http = { version = "0.6", features = ["cors", "trace", "limit"] } tokio-stream = { version = "0.1", features = ["sync"] } futures = "0.3" reqwest = { version = "0.12", features = ["json"] } diff --git a/backend/cli/src/main.rs b/backend/cli/src/main.rs index 3bc7b0a..d209baf 100644 --- a/backend/cli/src/main.rs +++ b/backend/cli/src/main.rs @@ -150,6 +150,15 @@ async fn run_server(config: Config) -> Result<()> { info!(url = %url, "Registered Ollama provider"); } + // Register every other catalog provider whose API key is present in the + // environment (OpenAI, Anthropic, Google, Mistral, xAI, Groq, and the major + // Chinese providers: DeepSeek, Qwen, Zhipu/GLM, Moonshot/Kimi, Baidu ERNIE, + // MiniMax, Tencent Hunyuan, 01.AI Yi, StepFun, Baichuan, iFlytek, SenseTime). + let extra = clawforge_planner::providers::catalog::register_from_env(&mut registry); + if !extra.is_empty() { + info!(providers = ?extra, "Registered additional model providers from environment"); + } + let registry = Arc::new(registry); // Wire up components diff --git a/backend/executor/src/executor.rs b/backend/executor/src/executor.rs index 469999e..2661d3f 100644 --- a/backend/executor/src/executor.rs +++ b/backend/executor/src/executor.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use clawforge_core::{ - AuditEventPayload, ClawError, Component, Event, EventKind, + AuditEventPayload, Capabilities, ClawError, Component, Event, EventKind, Message, ProposedAction, tools::ToolRegistry, }; diff --git a/backend/planner/src/providers/anthropic.rs b/backend/planner/src/providers/anthropic.rs new file mode 100644 index 0000000..2c1f48e --- /dev/null +++ b/backend/planner/src/providers/anthropic.rs @@ -0,0 +1,153 @@ +use std::time::Instant; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use clawforge_core::{LlmProvider, LlmRequest, LlmResponse}; + +/// Native Anthropic provider using the Messages API. +/// +/// Anthropic does not use the OpenAI chat-completions wire format, so it gets a +/// dedicated client: `POST {base}/v1/messages` with the `x-api-key` and +/// `anthropic-version` headers, a top-level `system` field, and a `messages` +/// array. +pub struct AnthropicProvider { + client: Client, + api_key: String, + base_url: String, + version: String, +} + +impl AnthropicProvider { + /// Build a provider from an API key (uses the public API base URL). + pub fn new(api_key: impl Into) -> Self { + Self { + client: Client::new(), + api_key: api_key.into(), + base_url: "https://api.anthropic.com".to_string(), + version: "2023-06-01".to_string(), + } + } + + /// Override the base URL (e.g. a gateway or Bedrock-style proxy). + pub fn with_base_url(mut self, url: impl Into) -> Self { + self.base_url = url.into(); + self + } +} + +#[derive(Serialize)] +struct MessagesRequest { + model: String, + max_tokens: u32, + temperature: f32, + #[serde(skip_serializing_if = "String::is_empty")] + system: String, + messages: Vec, +} + +#[derive(Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Deserialize)] +struct MessagesResponse { + content: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct ContentBlock { + #[serde(default)] + text: String, +} + +#[derive(Deserialize)] +struct AnthropicUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, +} + +#[async_trait] +impl LlmProvider for AnthropicProvider { + fn name(&self) -> &str { + "anthropic" + } + + async fn complete(&self, request: &LlmRequest) -> Result { + let start = Instant::now(); + + let body = MessagesRequest { + model: request.model.clone(), + max_tokens: request.max_tokens, + temperature: request.temperature, + system: request.system_prompt.clone(), + messages: vec![Message { + role: "user".to_string(), + content: request.user_prompt.clone(), + }], + }; + + debug!(model = %request.model, "Sending request to Anthropic"); + + let response = self + .client + .post(format!("{}/v1/messages", self.base_url.trim_end_matches('/'))) + .header("x-api-key", &self.api_key) + .header("anthropic-version", &self.version) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .context("Anthropic HTTP request failed")?; + + let status = response.status(); + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + anyhow::bail!("Anthropic returned {}: {}", status, error_body); + } + + let parsed: MessagesResponse = response + .json() + .await + .context("Failed to parse Anthropic response")?; + + let content = parsed + .content + .iter() + .map(|b| b.text.as_str()) + .collect::>() + .join(""); + + let tokens_used = parsed + .usage + .map(|u| u.input_tokens + u.output_tokens) + .unwrap_or(0); + let latency_ms = start.elapsed().as_millis() as u64; + + Ok(LlmResponse { + content, + provider: "anthropic".to_string(), + model: request.model.clone(), + tokens_used, + latency_ms, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_is_anthropic() { + assert_eq!(AnthropicProvider::new("sk-test").name(), "anthropic"); + } +} diff --git a/backend/planner/src/providers/catalog.rs b/backend/planner/src/providers/catalog.rs new file mode 100644 index 0000000..f4624a6 --- /dev/null +++ b/backend/planner/src/providers/catalog.rs @@ -0,0 +1,238 @@ +//! Catalog of supported model providers and env-driven registration. +//! +//! ClawForge speaks OpenAI-compatible chat-completions for almost every hosted +//! provider, with a native client for Anthropic and Ollama. This catalog is the +//! authoritative list the runtime uses to wire providers up from environment +//! variables, and the governance layer uses to reason about model origin and +//! data residency. + +use std::sync::Arc; + +use tracing::info; + +use super::anthropic::AnthropicProvider; +use super::openai_compatible::OpenAiCompatibleProvider; +use super::ProviderRegistry; + +/// Wire protocol a provider speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wire { + /// OpenAI-style `/chat/completions`. + OpenAiCompatible, + /// Anthropic Messages API. + Anthropic, + /// Native Ollama API (local). + Ollama, +} + +/// Hosting origin, relevant to data-residency review. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Region { + UnitedStates, + Europe, + China, + /// Multi-region aggregator/router. + Global, + /// Runs on the operator's own hardware. + Local, +} + +/// Static metadata about a model provider. +#[derive(Debug, Clone, Copy)] +pub struct ProviderInfo { + /// Stable id used by the registry and agent records. + pub id: &'static str, + /// Human-friendly name. + pub display_name: &'static str, + /// Hosting origin. + pub region: Region, + /// Base URL for the API. + pub base_url: &'static str, + /// Environment variable holding the credential (a URL for Ollama). + pub env_var: &'static str, + /// Wire protocol. + pub wire: Wire, + /// A few representative model names. + pub example_models: &'static [&'static str], + /// Short data-residency note for governance. + pub data_residency: &'static str, +} + +impl ProviderInfo { + /// Whether this provider warrants a data-residency review before approval + /// (anything not hosted locally or in the operator's own jurisdiction). + pub fn needs_residency_review(&self) -> bool { + !matches!(self.region, Region::Local) + } +} + +/// The full catalog of supported providers. +pub const CATALOG: &[ProviderInfo] = &[ + // ---- Global / Western ---- + ProviderInfo { id: "openrouter", display_name: "OpenRouter", region: Region::Global, + base_url: "https://openrouter.ai/api/v1", env_var: "OPENROUTER_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["anthropic/claude-opus-4-8", "openai/gpt-4o", "google/gemini-2.5-pro"], + data_residency: "Aggregator; routes to many providers and regions" }, + ProviderInfo { id: "openai", display_name: "OpenAI", region: Region::UnitedStates, + base_url: "https://api.openai.com/v1", env_var: "OPENAI_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["gpt-4o", "gpt-4.1", "o3"], data_residency: "US-hosted" }, + ProviderInfo { id: "anthropic", display_name: "Anthropic", region: Region::UnitedStates, + base_url: "https://api.anthropic.com", env_var: "ANTHROPIC_API_KEY", wire: Wire::Anthropic, + example_models: &["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4"], data_residency: "US-hosted" }, + ProviderInfo { id: "google", display_name: "Google Gemini", region: Region::UnitedStates, + base_url: "https://generativelanguage.googleapis.com/v1beta/openai", env_var: "GEMINI_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["gemini-2.5-pro", "gemini-2.5-flash"], data_residency: "US-hosted; OpenAI-compatible endpoint" }, + ProviderInfo { id: "mistral", display_name: "Mistral AI", region: Region::Europe, + base_url: "https://api.mistral.ai/v1", env_var: "MISTRAL_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["mistral-large-latest", "codestral-latest"], data_residency: "EU-hosted (France)" }, + ProviderInfo { id: "xai", display_name: "xAI Grok", region: Region::UnitedStates, + base_url: "https://api.x.ai/v1", env_var: "XAI_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["grok-4", "grok-3"], data_residency: "US-hosted" }, + ProviderInfo { id: "groq", display_name: "Groq", region: Region::UnitedStates, + base_url: "https://api.groq.com/openai/v1", env_var: "GROQ_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["llama-3.3-70b-versatile", "moonshotai/kimi-k2-instruct"], data_residency: "US-hosted" }, + ProviderInfo { id: "together", display_name: "Together AI", region: Region::UnitedStates, + base_url: "https://api.together.xyz/v1", env_var: "TOGETHER_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["meta-llama/Llama-3.3-70B-Instruct-Turbo", "Qwen/Qwen2.5-72B-Instruct"], data_residency: "US-hosted" }, + ProviderInfo { id: "fireworks", display_name: "Fireworks AI", region: Region::UnitedStates, + base_url: "https://api.fireworks.ai/inference/v1", env_var: "FIREWORKS_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["accounts/fireworks/models/deepseek-v3", "accounts/fireworks/models/qwen2p5-72b-instruct"], data_residency: "US-hosted" }, + ProviderInfo { id: "perplexity", display_name: "Perplexity", region: Region::UnitedStates, + base_url: "https://api.perplexity.ai", env_var: "PERPLEXITY_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["sonar", "sonar-pro"], data_residency: "US-hosted" }, + ProviderInfo { id: "cohere", display_name: "Cohere", region: Region::UnitedStates, + base_url: "https://api.cohere.ai/compatibility/v1", env_var: "COHERE_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["command-r-plus", "command-r"], data_residency: "North America-hosted" }, + ProviderInfo { id: "ollama", display_name: "Ollama (local)", region: Region::Local, + base_url: "http://localhost:11434", env_var: "OLLAMA_URL", wire: Wire::Ollama, + example_models: &["llama3.1", "qwen2.5", "deepseek-r1"], data_residency: "Runs on the operator's own hardware; no data leaves the host" }, + // ---- China ---- + ProviderInfo { id: "deepseek", display_name: "DeepSeek", region: Region::China, + base_url: "https://api.deepseek.com/v1", env_var: "DEEPSEEK_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["deepseek-chat", "deepseek-reasoner"], data_residency: "China-hosted; review residency for regulated data" }, + ProviderInfo { id: "qwen", display_name: "Alibaba Qwen (DashScope)", region: Region::China, + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", env_var: "DASHSCOPE_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["qwen-max", "qwen-plus", "qwen2.5-72b-instruct"], data_residency: "China-hosted; an international endpoint is also available" }, + ProviderInfo { id: "zhipu", display_name: "Zhipu AI (GLM)", region: Region::China, + base_url: "https://open.bigmodel.cn/api/paas/v4", env_var: "ZHIPU_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["glm-4.6", "glm-4-plus"], data_residency: "China-hosted" }, + ProviderInfo { id: "moonshot", display_name: "Moonshot AI (Kimi)", region: Region::China, + base_url: "https://api.moonshot.cn/v1", env_var: "MOONSHOT_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["moonshot-v1-128k", "kimi-k2-0905-preview"], data_residency: "China-hosted; a global endpoint is also available" }, + ProviderInfo { id: "baidu", display_name: "Baidu ERNIE (Qianfan)", region: Region::China, + base_url: "https://qianfan.baidubce.com/v2", env_var: "QIANFAN_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["ernie-4.5-turbo-128k", "ernie-4.5-8k"], data_residency: "China-hosted" }, + ProviderInfo { id: "minimax", display_name: "MiniMax", region: Region::China, + base_url: "https://api.minimax.chat/v1", env_var: "MINIMAX_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["minimax-text-01", "abab6.5s-chat"], data_residency: "China-hosted; an international endpoint is also available" }, + ProviderInfo { id: "tencent", display_name: "Tencent Hunyuan", region: Region::China, + base_url: "https://api.hunyuan.cloud.tencent.com/v1", env_var: "HUNYUAN_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["hunyuan-turbo", "hunyuan-large"], data_residency: "China-hosted" }, + ProviderInfo { id: "yi", display_name: "01.AI Yi (Lingyiwanwu)", region: Region::China, + base_url: "https://api.lingyiwanwu.com/v1", env_var: "YI_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["yi-large", "yi-lightning"], data_residency: "China-hosted" }, + ProviderInfo { id: "stepfun", display_name: "StepFun", region: Region::China, + base_url: "https://api.stepfun.com/v1", env_var: "STEPFUN_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["step-2-16k", "step-1v-8k"], data_residency: "China-hosted" }, + ProviderInfo { id: "baichuan", display_name: "Baichuan AI", region: Region::China, + base_url: "https://api.baichuan-ai.com/v1", env_var: "BAICHUAN_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["Baichuan4-Turbo", "Baichuan4"], data_residency: "China-hosted" }, + ProviderInfo { id: "iflytek", display_name: "iFlytek Spark", region: Region::China, + base_url: "https://spark-api-open.xf-yun.com/v1", env_var: "SPARK_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["generalv3.5", "4.0Ultra"], data_residency: "China-hosted" }, + ProviderInfo { id: "sensetime", display_name: "SenseTime SenseNova", region: Region::China, + base_url: "https://api.sensenova.cn/compatible-mode/v1", env_var: "SENSENOVA_API_KEY", wire: Wire::OpenAiCompatible, + example_models: &["SenseChat-5"], data_residency: "China-hosted" }, +]; + +/// Look up a provider by id. +pub fn get(id: &str) -> Option<&'static ProviderInfo> { + CATALOG.iter().find(|p| p.id == id) +} + +/// All providers hosted in a given region. +pub fn by_region(region: Region) -> Vec<&'static ProviderInfo> { + CATALOG.iter().filter(|p| p.region == region).collect() +} + +/// All China-hosted providers. +pub fn chinese() -> Vec<&'static ProviderInfo> { + by_region(Region::China) +} + +/// Register every catalog provider whose credential environment variable is set. +/// +/// `openrouter` and `ollama` are skipped here because the CLI wires them from +/// its own `Config` (so they are never double-registered). Returns the ids that +/// were registered. +pub fn register_from_env(registry: &mut ProviderRegistry) -> Vec { + let mut registered = Vec::new(); + for p in CATALOG { + if p.id == "openrouter" || p.id == "ollama" { + continue; + } + let key = match std::env::var(p.env_var) { + Ok(v) if !v.trim().is_empty() => v, + _ => continue, + }; + match p.wire { + Wire::Anthropic => { + registry.register(p.id, Arc::new(AnthropicProvider::new(key).with_base_url(p.base_url))); + } + Wire::OpenAiCompatible => { + registry.register(p.id, Arc::new(OpenAiCompatibleProvider::new(p.id, p.base_url, key))); + } + Wire::Ollama => continue, + } + info!(provider = p.id, region = ?p.region, "Registered model provider from env"); + registered.push(p.id.to_string()); + } + registered +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique() { + let mut ids: Vec<&str> = CATALOG.iter().map(|p| p.id).collect(); + let n = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate provider ids in catalog"); + } + + #[test] + fn covers_major_chinese_providers() { + for id in ["deepseek", "qwen", "zhipu", "moonshot", "baidu", "minimax", "tencent", "yi"] { + assert!(get(id).is_some(), "missing Chinese provider {id}"); + } + assert!(chinese().len() >= 10, "expected at least 10 China-hosted providers"); + } + + #[test] + fn anthropic_uses_native_wire() { + assert_eq!(get("anthropic").unwrap().wire, Wire::Anthropic); + assert_eq!(get("deepseek").unwrap().wire, Wire::OpenAiCompatible); + } + + #[test] + fn local_provider_skips_residency_review() { + assert!(!get("ollama").unwrap().needs_residency_review()); + assert!(get("deepseek").unwrap().needs_residency_review()); + } + + #[test] + fn register_from_env_picks_up_set_keys() { + // Use a provider unlikely to be set in the ambient environment. + std::env::set_var("STEPFUN_API_KEY", "sk-test"); + let mut reg = ProviderRegistry::new(); + let ids = register_from_env(&mut reg); + std::env::remove_var("STEPFUN_API_KEY"); + assert!(ids.contains(&"stepfun".to_string())); + assert!(reg.list().contains(&"stepfun".to_string())); + // openrouter/ollama are intentionally not registered here. + assert!(!ids.contains(&"ollama".to_string())); + } +} diff --git a/backend/planner/src/providers/mod.rs b/backend/planner/src/providers/mod.rs index 836d1b0..5467a66 100644 --- a/backend/planner/src/providers/mod.rs +++ b/backend/planner/src/providers/mod.rs @@ -1,6 +1,9 @@ pub mod openrouter; pub mod ollama; pub mod mock; +pub mod openai_compatible; +pub mod anthropic; +pub mod catalog; use std::collections::HashMap; use std::sync::Arc; diff --git a/backend/planner/src/providers/openai_compatible.rs b/backend/planner/src/providers/openai_compatible.rs new file mode 100644 index 0000000..962d767 --- /dev/null +++ b/backend/planner/src/providers/openai_compatible.rs @@ -0,0 +1,148 @@ +use std::time::Instant; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use clawforge_core::{LlmProvider, LlmRequest, LlmResponse}; + +/// A generic provider for any service that exposes an OpenAI-compatible +/// `/chat/completions` endpoint. +/// +/// The overwhelming majority of hosted model providers (OpenAI, Google Gemini's +/// compatibility endpoint, Mistral, xAI, Groq, Together, Fireworks, and the major +/// Chinese providers such as DeepSeek, Qwen/DashScope, Zhipu GLM, Moonshot/Kimi, +/// Baidu ERNIE, MiniMax, Tencent Hunyuan, StepFun, Baichuan, and iFlytek Spark) +/// speak this protocol, so a single client covers them all. Pass the provider +/// id, its base URL, and the API key. +pub struct OpenAiCompatibleProvider { + client: Client, + name: String, + api_key: String, + base_url: String, +} + +impl OpenAiCompatibleProvider { + /// Build a provider for the given id, base URL, and API key. + pub fn new(name: impl Into, base_url: impl Into, api_key: impl Into) -> Self { + Self { + client: Client::new(), + name: name.into(), + api_key: api_key.into(), + base_url: base_url.into(), + } + } +} + +#[derive(Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + max_tokens: Option, + temperature: Option, +} + +#[derive(Serialize, Deserialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Deserialize)] +struct ChatResponse { + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct Choice { + message: ChatMessage, +} + +#[derive(Deserialize)] +struct Usage { + total_tokens: Option, +} + +#[async_trait] +impl LlmProvider for OpenAiCompatibleProvider { + fn name(&self) -> &str { + &self.name + } + + async fn complete(&self, request: &LlmRequest) -> Result { + let start = Instant::now(); + + let mut messages = Vec::new(); + if !request.system_prompt.is_empty() { + messages.push(ChatMessage { + role: "system".to_string(), + content: request.system_prompt.clone(), + }); + } + messages.push(ChatMessage { + role: "user".to_string(), + content: request.user_prompt.clone(), + }); + + let body = ChatRequest { + model: request.model.clone(), + messages, + max_tokens: Some(request.max_tokens), + temperature: Some(request.temperature), + }; + + debug!(provider = %self.name, model = %request.model, "Sending OpenAI-compatible request"); + + let response = self + .client + .post(format!("{}/chat/completions", self.base_url.trim_end_matches('/'))) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .with_context(|| format!("{} HTTP request failed", self.name))?; + + let status = response.status(); + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + anyhow::bail!("{} returned {}: {}", self.name, status, error_body); + } + + let chat_response: ChatResponse = response + .json() + .await + .with_context(|| format!("Failed to parse {} response", self.name))?; + + let content = chat_response + .choices + .first() + .map(|c| c.message.content.clone()) + .unwrap_or_default(); + + let tokens_used = chat_response.usage.and_then(|u| u.total_tokens).unwrap_or(0); + let latency_ms = start.elapsed().as_millis() as u64; + + Ok(LlmResponse { + content, + provider: self.name.clone(), + model: request.model.clone(), + tokens_used, + latency_ms, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_is_reported() { + let p = OpenAiCompatibleProvider::new("deepseek", "https://api.deepseek.com/v1", "sk-test"); + assert_eq!(p.name(), "deepseek"); + } +} diff --git a/docs/README.md b/docs/README.md index c46867b..3f4e88c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Index of the control-plane documentation. - [Architecture](architecture.md) — the two layers and how they relate. - [Diagrams](diagrams.md) — architecture, lifecycle, governance, and MCP flows. - [Installation guide](installation.md) — prerequisites, config, build & test. +- [Model providers](model-providers.md) — every supported provider and how to enable it. - [Demo](demo.md) — runnable end-to-end walkthrough. ## Capabilities diff --git a/docs/model-providers.md b/docs/model-providers.md new file mode 100644 index 0000000..c164708 --- /dev/null +++ b/docs/model-providers.md @@ -0,0 +1,78 @@ +# Model Providers + +ClawForge is multi-provider. The runtime speaks the OpenAI-compatible +`/chat/completions` protocol for almost every hosted provider, with a native +client for Anthropic and a native client for local Ollama. A single catalog +(`clawforge_planner::providers::catalog`) is the source of truth, and the +control plane governs whichever providers you approve (the Agent Registry stores +`model_provider` plus `model_name`, and the Security Gateway checks the action's +model against the agent's approved model). + +## How registration works + +On `clawforge-cli serve`, the runtime registers every provider whose API key is +present in the environment. Nothing is hard-coded to a single vendor: set the +keys you want and those providers become available. Ollama needs no key (set +`OLLAMA_URL` or use the local default), and OpenRouter is an aggregator that +reaches many models through one key. + +```bash +export ANTHROPIC_API_KEY="sk-ant-..." +export DEEPSEEK_API_KEY="sk-..." +cargo run -p clawforge-cli -- serve +# logs: Registered additional model providers from environment ["anthropic", "deepseek"] +``` + +## Supported providers + +### Global and Western + +| id | Provider | Region | Wire | Env var | +|----|----------|--------|------|---------| +| `openrouter` | OpenRouter (aggregator) | Global | OpenAI-compatible | `OPENROUTER_API_KEY` | +| `openai` | OpenAI | US | OpenAI-compatible | `OPENAI_API_KEY` | +| `anthropic` | Anthropic | US | Native Messages API | `ANTHROPIC_API_KEY` | +| `google` | Google Gemini | US | OpenAI-compatible | `GEMINI_API_KEY` | +| `mistral` | Mistral AI | EU | OpenAI-compatible | `MISTRAL_API_KEY` | +| `xai` | xAI Grok | US | OpenAI-compatible | `XAI_API_KEY` | +| `groq` | Groq | US | OpenAI-compatible | `GROQ_API_KEY` | +| `together` | Together AI | US | OpenAI-compatible | `TOGETHER_API_KEY` | +| `fireworks` | Fireworks AI | US | OpenAI-compatible | `FIREWORKS_API_KEY` | +| `perplexity` | Perplexity | US | OpenAI-compatible | `PERPLEXITY_API_KEY` | +| `cohere` | Cohere | North America | OpenAI-compatible | `COHERE_API_KEY` | +| `ollama` | Ollama (local) | Local | Native | `OLLAMA_URL` | + +### Chinese + +| id | Provider | Region | Wire | Env var | +|----|----------|--------|------|---------| +| `deepseek` | DeepSeek | China | OpenAI-compatible | `DEEPSEEK_API_KEY` | +| `qwen` | Alibaba Qwen (DashScope) | China | OpenAI-compatible | `DASHSCOPE_API_KEY` | +| `zhipu` | Zhipu AI (GLM) | China | OpenAI-compatible | `ZHIPU_API_KEY` | +| `moonshot` | Moonshot AI (Kimi) | China | OpenAI-compatible | `MOONSHOT_API_KEY` | +| `baidu` | Baidu ERNIE (Qianfan) | China | OpenAI-compatible | `QIANFAN_API_KEY` | +| `minimax` | MiniMax | China | OpenAI-compatible | `MINIMAX_API_KEY` | +| `tencent` | Tencent Hunyuan | China | OpenAI-compatible | `HUNYUAN_API_KEY` | +| `yi` | 01.AI Yi (Lingyiwanwu) | China | OpenAI-compatible | `YI_API_KEY` | +| `stepfun` | StepFun | China | OpenAI-compatible | `STEPFUN_API_KEY` | +| `baichuan` | Baichuan AI | China | OpenAI-compatible | `BAICHUAN_API_KEY` | +| `iflytek` | iFlytek Spark | China | OpenAI-compatible | `SPARK_API_KEY` | +| `sensetime` | SenseTime SenseNova | China | OpenAI-compatible | `SENSENOVA_API_KEY` | + +## Data residency and governance + +Each catalog entry carries a `region` and a short `data_residency` note. For +government and regulated deployments this matters: routing regulated or PII data +to a provider hosted in another jurisdiction may breach data-protection rules. +The catalog flags every non-local provider with `needs_residency_review()`, and +the compliance pack (`ExportControl`, `PiiClassification`) is the place to record +and enforce the decision. Local Ollama keeps all data on the operator's hardware +and is the safest default for sensitive workloads. + +## Adding a provider + +Most providers are one catalog line because they are OpenAI-compatible. Add a +`ProviderInfo` to `CATALOG` in `backend/planner/src/providers/catalog.rs` with +the id, base URL, env var, and `Wire::OpenAiCompatible`. Providers with a +bespoke wire format get a dedicated client implementing the `LlmProvider` trait +(see `anthropic.rs` for the pattern) and a matching `Wire` variant.