Mithril Providers — How to Add & Understand Providers




Providers
5
Gemini, OpenAI, Anthropic, Groq, Local
Shared Interface
5 methods
ChatProvider trait
Pattern
Strategy
swap providers without touching orchestrator

What is a Provider?

A Provider is Mithril's adapter for a specific LLM API. It translates the unified ChatProvider interface into the HTTP calls, headers, and payload format that each API expects.

The orchestrator never knows which provider it's using — it just calls provider.chat(messages) and gets a response. This means you can swap Gemini for GPT-4 by changing one line in the fellowship YAML.

Provider Selection Flow

flowchart LR
    ORCH(Orchestrator) -->|ChatProvider trait| FACTORY(Factory)
    FACTORY --> GEM(Gemini)
    FACTORY --> OAI(OpenAI)
    FACTORY --> ANT(Anthropic)
    FACTORY --> GRQ(Groq)
    FACTORY --> LOC(Local GGUF)

The ChatProvider Trait — 5 Methods Every Provider Implements

The Interface Contract

pub trait ChatProvider: Send + Sync {
    /// Provider identifier (e.g. "gemini", "openai")
    fn name(&self) -> &str;
    
    /// Model being used (e.g. "gemini-2.5-flash", "gpt-4o")
    fn model(&self) -> &str;
    
    /// Simple chat: send messages, get full response back
    async fn chat(&self, messages: &[ChatMessage]) -> Result<String>;
    
    /// Streaming chat: tokens arrive one by one via a channel
    async fn chat_stream(
        &self, messages: &[ChatMessage], tx: Sender<String>
    ) -> Result<String>;
    
    /// Chat with function calling: send tools + messages, get back
    /// either a text response OR a list of tool calls to execute
    async fn chat_with_tools(
        &self, messages: &[ChatMessage], tools: &[ToolDefinition]
    ) -> Result<ChatResponse>;
    
    /// Health check: is the API reachable and key valid?
    async fn is_available(&self) -> bool;
}

What it does

Takes a conversation history (list of messages with roles: system, user, assistant) and returns the model's complete response as a single string.

The flow (same for ALL providers)

  1. Convert messages to the provider's native format
  2. Build HTTP request with auth headers and JSON payload
  3. POST to the provider's API endpoint
  4. Parse response — extract the assistant's text from the provider-specific JSON
  5. Return the text as a String

What differs per provider

Download as CSV
Provider Message Format Auth Endpoint
Gemini contents[{role, parts[{text}]}] ?key= in URL generativelanguage.googleapis.com
OpenAI messages[{role, content}] Bearer token header api.openai.com/v1/chat/completions
Anthropic messages[] + system separate x-api-key header api.anthropic.com/v1/messages
Groq messages[{role, content}] (OpenAI-compatible) Bearer token header api.groq.com/openai/v1/chat/completions
Local Direct token generation (no HTTP) N/A In-process GGUF inference

What it does

Same as chat() but tokens arrive one by one as they're generated. Used by the TUI and API to show responses in real-time.

The flow

  1. Same request building as chat(), but add "stream": true to the payload
  2. Open SSE connection — server sends chunks as Server-Sent Events
  3. For each chunk: parse the delta token, send it through the tx channel
  4. Accumulate all tokens into the final complete response
  5. Return the full text (the caller also got each token via the channel)

What differs per provider

What it does

Sends messages + a list of available tools. The model can either respond with text OR request that specific tools be executed with specific arguments.

The flow

  1. Convert tool definitions to the provider's native format (JSON Schema for most)
  2. Add tools to the request payload alongside messages
  3. POST to the provider
  4. Parse response: check if it's a text reply or tool_calls
  5. If tool_calls: return structured ToolCall {name, arguments} objects
  6. If text: return the text response directly

What differs per provider

Download as CSV
Provider Tool Format Response Location Parallel Calls
Gemini functionDeclarations[{name, parameters}] candidates[0].content.parts[].functionCall Yes
OpenAI tools[{type:function, function:{name, parameters}}] choices[0].message.tool_calls[] Yes
Anthropic tools[{name, input_schema}] content[].type == tool_use Yes
Groq tools[] (same as OpenAI) choices[0].message.tool_calls[] Yes
Local Grammar-constrained JSON output Parsed from model text output No (sequential)

To add a new LLM provider (e.g. Mistral, Cohere, DeepSeek cloud), follow this template. Create a new file src/providers/your_provider.rs:

New Provider Template (copy and fill in)

use anyhow::Result;
use async_trait::async_trait;
use crate::providers::{ChatMessage, ChatProvider, ChatResponse, ToolDefinition};
use tokio::sync::mpsc::Sender;

pub struct YourProvider {
    api_key: String,
    model: String,
    client: reqwest::Client,
}

impl YourProvider {
    pub fn new(api_key: String, model: &str) -> Self {
        Self {
            api_key,
            model: model.to_string(),
            client: reqwest::Client::new(),
        }
    }
}

#[async_trait]
impl ChatProvider for YourProvider {
    fn name(&self) -> &str { "your_provider" }
    fn model(&self) -> &str { &self.model }

    async fn chat(&self, messages: &[ChatMessage]) -> Result<String> {
        // 1. Convert messages to your API's format
        let payload = build_payload(messages);
        
        // 2. POST to your API
        let response = self.client
            .post("https://api.yourprovider.com/v1/chat")
            .bearer_auth(&self.api_key)
            .json(&payload)
            .send().await?
            .error_for_status()?
            .json::<YourResponse>().await?;
        
        // 3. Extract and return the assistant's text
        Ok(response.choices[0].message.content.clone())
    }

    async fn chat_stream(
        &self, messages: &[ChatMessage], tx: Sender<String>
    ) -> Result<String> {
        // Same as chat() but with "stream": true
        // Read SSE chunks, send each token via tx.send(token)
        // Return the full accumulated text
        todo!("Implement streaming")
    }

    async fn chat_with_tools(
        &self, messages: &[ChatMessage], tools: &[ToolDefinition]
    ) -> Result<ChatResponse> {
        // Same as chat() but include tool definitions in payload
        // Parse response: if tool_calls present, return them
        // Otherwise return text
        todo!("Implement tool calling")
    }

    async fn is_available(&self) -> bool {
        !self.api_key.is_empty()
    }
}

Then register it in the factory (src/providers/mod.rs):

Factory Registration

// In create_provider_with_model():
"your_provider" => {
    let key = config.get_credential("your_provider")?
        .ok_or_else(|| anyhow!("API key not set"))?;
    Ok(Box::new(YourProvider::new(key, model)))
}

That's it. The orchestrator, API layer, CLI, and TUI all work automatically with your new provider. No other files need changes.

These are tracked in the refactoring roadmap. The architecture is correct — the duplication is cosmetic and doesn't affect functionality or extensibility.