Skip to main content

Adding a Provider

Providers connect ClawDesk to AI model backends (Anthropic, OpenAI, Ollama, etc.). This guide walks through adding a new provider integration.

Provider Architecture

┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│ Agent │────▶│ Provider │────▶│ External │
│ Pipeline │ │ Registry │ │ API │
└──────────────┘ └──────────────────┘ └──────────────┘

┌─────┴──────┐
│ Negotiator │ ← Capability matching
└────────────┘
│ Fallback │ ← Automatic retry
└────────────┘

Step 1: Create the Provider Module

Add a new module in crates/clawdesk-providers/src/:

crates/clawdesk-providers/src/
├── lib.rs
├── anthropic.rs
├── openai.rs
├── ollama.rs
├── gemini.rs
├── bedrock.rs
└── mistral.rs ← New file

Step 2: Define Configuration

// crates/clawdesk-providers/src/mistral.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MistralConfig {
pub api_key: String,
pub base_url: Option<String>,
pub default_model: String,
pub max_retries: u32,
pub timeout_secs: u64,
}

impl Default for MistralConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: Some("https://api.mistral.ai/v1".into()),
default_model: "mistral-large-latest".into(),
max_retries: 3,
timeout_secs: 30,
}
}
}

Step 3: Implement the Provider Trait

use async_trait::async_trait;
use clawdesk_types::{Message, ModelId, ProviderError};

pub struct MistralProvider {
config: MistralConfig,
client: reqwest::Client,
}

#[async_trait]
impl Provider for MistralProvider {
fn name(&self) -> &str {
"mistral"
}

fn models(&self) -> Vec<ModelId> {
vec![
ModelId::from("mistral-large-latest"),
ModelId::from("mistral-medium-latest"),
ModelId::from("mistral-small-latest"),
ModelId::from("open-mixtral-8x22b"),
]
}

async fn chat(
&self,
model: &ModelId,
messages: &[Message],
options: &ChatOptions,
) -> Result<Message, ProviderError> {
let request = self.build_request(model, messages, options);

let response = self
.client
.post(format!("{}/chat/completions", self.base_url()))
.bearer_auth(&self.config.api_key)
.json(&request)
.send()
.await
.map_err(|e| ProviderError::ConnectionFailed(e.to_string()))?;

self.parse_response(response).await
}

async fn stream(
&self,
model: &ModelId,
messages: &[Message],
options: &ChatOptions,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, ProviderError>>>>, ProviderError> {
// SSE streaming implementation
todo!()
}
}

Step 4: Map Errors

Map provider-specific errors to ProviderError:

impl MistralProvider {
fn map_api_error(&self, status: StatusCode, body: &str) -> ProviderError {
match status {
StatusCode::UNAUTHORIZED => ProviderError::AuthFailed,
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = self.parse_retry_after(body);
ProviderError::RateLimited { retry_after }
}
StatusCode::BAD_REQUEST => ProviderError::InvalidRequest(body.to_string()),
_ => ProviderError::ApiError {
status: status.as_u16(),
message: body.to_string(),
},
}
}
}
tip

The fallback system uses error types to decide whether to retry with a different provider. Return ProviderError::RateLimited for 429s to enable automatic fallback.

Step 5: Declare Capabilities

Register the provider's capabilities with the negotiator:

use clawdesk_providers::capability::{Capability, CapabilitySet};

impl MistralProvider {
pub fn capabilities(&self, model: &ModelId) -> CapabilitySet {
let mut caps = CapabilitySet::new();
caps.insert(Capability::Chat);
caps.insert(Capability::Streaming);

if model.as_str().contains("large") {
caps.insert(Capability::FunctionCalling);
caps.insert(Capability::JsonMode);
}

caps
}
}

Step 6: Register the Provider

Add to the provider registry in crates/clawdesk-providers/src/registry.rs:

pub fn register_providers(
registry: &mut ProviderRegistry,
config: &AppConfig,
) -> Result<(), ProviderError> {
// ... existing providers ...

if let Some(mistral_cfg) = &config.providers.mistral {
let provider = MistralProvider::new(mistral_cfg.clone())?;
registry.register(Box::new(provider));
}

Ok(())
}

Step 7: Integrate with Fallback

The fallback system in clawdesk-domain automatically picks up registered providers. Configure the fallback chain in config.toml:

[fallback]
chain = ["anthropic", "mistral", "ollama"]
strategy = "sequential" # or "capability-match"
max_retries_per_provider = 2

Step 8: Write Tests

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn mistral_default_config_is_valid() {
let config = MistralConfig::default();
assert_eq!(config.default_model, "mistral-large-latest");
assert!(config.base_url.is_some());
}

#[test]
fn mistral_lists_models() {
let provider = MistralProvider::new(MistralConfig::default()).unwrap();
let models = provider.models();
assert!(models.len() >= 3);
}

#[test]
fn error_mapping_rate_limit() {
let provider = MistralProvider::new(MistralConfig::default()).unwrap();
let err = provider.map_api_error(StatusCode::TOO_MANY_REQUESTS, "{}");
assert!(matches!(err, ProviderError::RateLimited { .. }));
}

#[tokio::test]
async fn mistral_chat_handles_auth_failure() {
let config = MistralConfig {
api_key: "invalid".into(),
..Default::default()
};
let provider = MistralProvider::new(config).unwrap();
let result = provider
.chat(
&ModelId::from("mistral-small-latest"),
&[Message::user("test")],
&ChatOptions::default(),
)
.await;
assert!(result.is_err());
}
}

Checklist

  • Implement Provider trait (name, models, chat, stream)
  • Define {Provider}Config with serde
  • Map all API errors to ProviderError variants
  • Declare CapabilitySet per model
  • Register in ProviderRegistry
  • Configure fallback chain entry
  • Write unit tests for config, models, and error mapping
  • Write integration tests for chat round-trip
  • Add environment variable for API key
  • Update provider docs
info

See the Anthropic and OpenAI implementations in crates/clawdesk-providers/src/ for complete reference examples.