opencrabs 0.3.8

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
# Adding New Providers

This document describes how to add a new AI provider to OpenCrabs.

## Overview

OpenCrabs supports multiple AI providers through a pluggable architecture. Each provider is configured via `config.toml` and appears in the onboarding UI.

## Provider Architecture

### 1. Config (`src/config/types.rs`)

Add provider config under `ProviderConfigs`:

```rust
pub struct ProviderConfigs {
    pub minimax: Option<ProviderConfig>,   // NEW
    // ... existing providers
}

pub struct ProviderConfig {
    pub enabled: bool,
    pub api_key: Option<String>,
    pub base_url: Option<String>,
    pub default_model: Option<String>,
}
```

### 2. Provider Factory (`src/brain/provider/factory.rs`)

Add provider creation logic in priority order:

```rust
pub fn create_provider(config: &Config) -> Result<Arc<dyn Provider>> {
    // Check which providers are enabled in config.toml
    
    // NEW: Add your provider
    if config.providers.yourprovider.as_ref().is_some_and(|p| p.enabled) {
        tracing::info!("Using enabled provider: YourProvider");
        return try_create_yourprovider(config)?
            .ok_or_else(|| anyhow::anyhow!("YourProvider enabled but failed"));
    }
    
    // ... existing providers
}
```

Add helper function:
```rust
fn try_create_yourprovider(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
    let cfg = match &config.providers.yourprovider {
        Some(c) => c,
        None => return Ok(None),
    };
    
    let api_key = match &cfg.api_key {
        Some(k) => k.clone(),
        None => return Ok(None),
    };
    
    let base_url = cfg.base_url.clone()
        .unwrap_or_else(|| "https://api.yourprovider.com/v1".to_string());
    
    let provider = configure_openai_compatible(
        OpenAIProvider::with_base_url(api_key, base_url),
        cfg,
    );
    Ok(Some(Arc::new(provider)))
}
```

### 3. Onboarding UI (`src/tui/onboarding.rs`)

Add provider to `PROVIDERS` array (index order matters!):

```rust
pub const PROVIDERS: &[ProviderInfo] = &[
    // ... existing
    ProviderInfo {
        name: "YourProvider",
        env_vars: &["YOURPROVIDER_API_KEY"],
        keyring_key: "yourprovider_api_key",
        models: &[],  // Always empty - fetched from API
        key_label: "API Key",
        help_lines: &["Get key from yourprovider.com"],
    },
];
```

Update `supports_model_fetch()` if provider supports model fetching:
```rust
pub fn supports_model_fetch(&self) -> bool {
    matches!(self.selected_provider, 0 | 1 | 3 | 4 | 5) // Add your index
}
```

### 4. Provider Display (`src/config/types.rs`)

Add to `resolve_provider_from_config()`:
```rust
pub fn resolve_provider_from_config(config: &Config) -> (&str, &str) {
    if config.providers.yourprovider.as_ref().is_some_and(|p| p.enabled) {
        let model = config.providers.yourprovider.as_ref()
            .and_then(|p| p.default_model.as_deref())
            .unwrap_or("default");
        return ("YourProvider", model);
    }
    // ...
}
```

### 5. Onboarding Save Logic (`src/tui/onboarding.rs`)

In `save_to_config()`:
```rust
match self.selected_provider {
    0 => { /* Anthropic */ }
    // ...
    N => {  // Your provider index
        config.providers.yourprovider = Some(ProviderConfig {
            enabled: true,
            api_key: Some(self.api_key_input.clone()),
            base_url: Some("https://api.yourprovider.com/v1".to_string()),
            default_model: Some(model),
        });
    }
}
```

### 6. Model Fetching

Providers are categorized by how they get their model list:

#### API Fetch (OpenAI, OpenRouter, Anthropic)
These providers have `/models` endpoints - models are fetched automatically:
- Base URL is modified: `base_url.replace("/chat/completions", "/models")`
- No need to save models in config

#### Config-Based (Minimax, Custom, etc.)
These providers DO NOT have `/models` endpoints:
- Add `models: Vec<String>` to config.toml
- API key goes in keys.toml (chmod 600)
- Example config.toml for Minimax:
```toml
[providers.minimax]
enabled = true
base_url = "https://api.minimax.io/v1"
default_model = "MiniMax-M2.7"
models = ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-Text-01"]
```

And in keys.toml:
```toml
[providers.minimax]
api_key = "your-key"
```

In `ProviderConfig` struct:
```rust
pub struct ProviderConfig {
    pub enabled: bool,
    pub api_key: Option<String>,
    pub base_url: Option<String>,
    pub default_model: Option<String>,
    pub models: Vec<String>,  // For providers without /models endpoint
}
```

In onboarding UI `all_model_names()`:
```rust
pub fn all_model_names(&self) -> Vec<&str> {
    if !self.fetched_models.is_empty() {
        // API fetch took priority
        self.fetched_models.iter().map(|s| s.as_str()).collect()
    } else if !self.config_models.is_empty() {
        // Use models from config.toml
        self.config_models.iter().map(|s| s.as_str()).collect()
    } else {
        // Fallback to static list
        self.current_provider().models.to_vec()
    }
}
```

## Config.toml Example

### For providers with API model fetch (OpenAI, OpenRouter, Anthropic):
config.toml:
```toml
[providers.openrouter]
enabled = true
base_url = "https://openrouter.ai/api/v1/chat/completions"
default_model = "qwen/qwen3-coder-next"
```

keys.toml:
```toml
[providers.openrouter]
api_key = "sk-or-v1-..."
```

### For providers WITHOUT API model fetch (Minimax, Custom):
```toml
[providers.minimax]
enabled = true
api_key = "your-key"
base_url = "https://api.minimax.io/v1"
default_model = "MiniMax-M2.7"
models = ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-Text-01"]
```

## Fallback Provider

To add fallback support (use if primary fails):

```toml
[providers.fallback]
enabled = true
provider = "yourprovider"  # or "minimax", "openrouter", etc.
```

## Provider Index Reference

Current provider indices in onboarding UI:
- 0: Anthropic Claude
- 1: OpenAI  
- 2: Google Gemini
- 3: OpenRouter
- 4: Minimax
- 5: Custom (local Ollama, etc.)

---

## Provider Requirements (Mandatory)

All new providers MUST implement the following to ensure full functionality.

> **Reference Implementation:** See `src/brain/provider/openai.rs` for the MiniMax implementation that handles streaming, tool calls, and token usage. Search for `minimax`, `stream_options`, `MessageDelta`, and `usage`.

### 1. Streaming Support

Use `stream_options: { include_usage: true }` in the request body:

```rust
openai_request.stream_options = Some(StreamOptions { include_usage: true });
```

Parse chunks and accumulate tool call arguments across chunks (may arrive partially).

### 2. Tool Calls

- Support tool call streaming (arguments may come in multiple chunks)
- Accumulate arguments until valid JSON received
- Log granular tool call events with `[TOOL_PARSE]` prefix
- Handle both `delta` and `message` fields in chunks (some providers send final tool_calls in `message`)

```rust
// Example: accumulate arguments
let args = &tc_item.function.arguments;
let args_trimmed = args.trim();
let is_valid_json = !args_trimmed.is_empty() 
    && args_trimmed != "{}"
    && serde_json::from_str::<serde_json::Value>(args).is_ok();

if !is_valid_json {
    tracing::warn!("[TOOL_PARSE] ⚠️ Tool '{}' args INCOMPLETE, skipping emit", name);
    continue; // Wait for next chunk with complete data
}
```

### 3. Token Usage (Critical)

Extract `usage` field from the final chunk:

```rust
// Add usage field to stream chunk struct
struct OpenAIStreamChunk {
    id: String,
    choices: Vec<OpenAIStreamChoice>,
    usage: Option<OpenAIUsage>,  // MUST HAVE
}

// Extract and emit usage
if let Some(ref usage) = chunk.usage {
    let finish_reason = chunk.choices.first().and_then(|c| c.finish_reason.as_ref());
    if finish_reason.is_some() {
        let input_tokens = usage.prompt_tokens.unwrap_or(0);
        let output_tokens = usage.completion_tokens.unwrap_or(0);
        tracing::info!("[STREAM_USAGE] Final chunk usage: input={}, output={}", input_tokens, output_tokens);
        
        events.push(Ok(StreamEvent::MessageDelta {
            delta: MessageDelta {
                stop_reason: Some(StopReason::EndTurn),
                stop_sequence: None,
            },
            usage: TokenUsage { input_tokens, output_tokens },
        }));
    }
}
```

### 4. Error Handling

- Graceful degradation on parse failures
- Don't discard accumulated data on errors
- Log parse errors with `[STREAM_PARSE]` prefix

### 5. Provider Struct Requirements

Ensure OpenAIUsage fields are optional to handle missing data:

```rust
#[derive(Debug, Clone, Deserialize)]
struct OpenAIUsage {
    #[serde(rename = "prompt_tokens")]
    prompt_tokens: Option<u32>,
    #[serde(rename = "completion_tokens")]
    completion_tokens: Option<u32>,
}
```