Skip to main content

apollo/providers/
openai_compat.rs

1//! OpenAI-compatible provider — works with OpenAI, OpenRouter, Groq, Together, etc.
2
3use async_trait::async_trait;
4use serde_json::Value;
5
6use super::retry::send_with_retry;
7use super::traits::*;
8use crate::text::truncate_chars;
9use crate::tools::ToolSpec;
10
11pub struct OpenAiCompatProvider {
12    api_key: String,
13    base_url: String,
14    provider_name: String,
15}
16
17impl OpenAiCompatProvider {
18    pub fn new(
19        api_key: impl Into<String>,
20        base_url: impl Into<String>,
21        name: impl Into<String>,
22    ) -> Self {
23        Self {
24            api_key: api_key.into(),
25            base_url: base_url.into(),
26            provider_name: name.into(),
27        }
28    }
29
30    /// OpenAI
31    pub fn openai(api_key: impl Into<String>) -> Self {
32        Self::new(api_key, "https://api.openai.com/v1", "openai")
33    }
34
35    /// OpenRouter
36    pub fn openrouter(api_key: impl Into<String>) -> Self {
37        Self::new(api_key, "https://openrouter.ai/api/v1", "openrouter")
38    }
39
40    /// Groq
41    pub fn groq(api_key: impl Into<String>) -> Self {
42        Self::new(api_key, "https://api.groq.com/openai/v1", "groq")
43    }
44
45    /// Together AI
46    pub fn together(api_key: impl Into<String>) -> Self {
47        Self::new(api_key, "https://api.together.xyz/v1", "together")
48    }
49
50    /// Mistral AI
51    pub fn mistral(api_key: impl Into<String>) -> Self {
52        Self::new(api_key, "https://api.mistral.ai/v1", "mistral")
53    }
54
55    /// DeepSeek
56    pub fn deepseek(api_key: impl Into<String>) -> Self {
57        Self::new(api_key, "https://api.deepseek.com/v1", "deepseek")
58    }
59
60    /// Fireworks AI
61    pub fn fireworks(api_key: impl Into<String>) -> Self {
62        Self::new(
63            api_key,
64            "https://api.fireworks.ai/inference/v1",
65            "fireworks",
66        )
67    }
68
69    /// Perplexity AI
70    pub fn perplexity(api_key: impl Into<String>) -> Self {
71        Self::new(api_key, "https://api.perplexity.ai", "perplexity")
72    }
73
74    /// xAI (Grok)
75    pub fn xai(api_key: impl Into<String>) -> Self {
76        Self::new(api_key, "https://api.x.ai/v1", "xai")
77    }
78
79    /// Moonshot / Kimi
80    pub fn moonshot(api_key: impl Into<String>) -> Self {
81        Self::new(api_key, "https://api.moonshot.ai/v1", "moonshot")
82    }
83
84    /// Venice AI
85    pub fn venice(api_key: impl Into<String>) -> Self {
86        Self::new(api_key, "https://api.venice.ai/api/v1", "venice")
87    }
88
89    /// HuggingFace Inference
90    pub fn huggingface(api_key: impl Into<String>) -> Self {
91        Self::new(
92            api_key,
93            "https://api-inference.huggingface.co/v1",
94            "huggingface",
95        )
96    }
97
98    /// SiliconFlow
99    pub fn siliconflow(api_key: impl Into<String>) -> Self {
100        Self::new(api_key, "https://api.siliconflow.cn/v1", "siliconflow")
101    }
102
103    /// Cerebras
104    pub fn cerebras(api_key: impl Into<String>) -> Self {
105        Self::new(api_key, "https://api.cerebras.ai/v1", "cerebras")
106    }
107
108    /// MiniMax (Anthropic-compatible)
109    pub fn minimax(api_key: impl Into<String>) -> Self {
110        Self::new(api_key, "https://api.minimax.io/v1", "minimax")
111    }
112
113    /// Vercel AI Gateway
114    pub fn vercel(api_key: impl Into<String>) -> Self {
115        Self::new(api_key, "https://gateway.vercel.ai/v1", "vercel")
116    }
117
118    /// Cloudflare Workers AI
119    pub fn cloudflare(api_key: impl Into<String>, account_id: &str) -> Self {
120        Self::new(
121            api_key,
122            format!(
123                "https://api.cloudflare.com/client/v4/accounts/{}/ai/v1",
124                account_id
125            ),
126            "cloudflare",
127        )
128    }
129
130    fn build_tools_payload(&self, tools: &[ToolSpec]) -> Vec<Value> {
131        tools
132            .iter()
133            .map(|t| {
134                serde_json::json!({
135                    "type": "function",
136                    "function": {
137                        "name": t.name,
138                        "description": t.description,
139                        "parameters": t.parameters,
140                    }
141                })
142            })
143            .collect()
144    }
145}
146
147#[async_trait]
148impl Provider for OpenAiCompatProvider {
149    fn name(&self) -> &str {
150        &self.provider_name
151    }
152
153    fn capabilities(&self) -> ProviderCapabilities {
154        ProviderCapabilities {
155            native_tools: true,
156            streaming: true,
157            vision: true,
158            max_context: 128_000,
159        }
160    }
161
162    async fn chat(&self, request: &ChatRequest<'_>) -> anyhow::Result<ChatResponse> {
163        let client = reqwest::Client::new();
164
165        let messages: Vec<Value> = request
166            .messages
167            .iter()
168            .map(|m| serde_json::json!({ "role": &m.role, "content": &m.content }))
169            .collect();
170
171        let mut body = serde_json::json!({
172            "model": request.model,
173            "messages": messages,
174            "temperature": request.temperature,
175        });
176
177        if let Some(max) = request.max_tokens {
178            body["max_tokens"] = Value::Number(max.into());
179        }
180
181        if let Some(tools) = request.tools {
182            if !tools.is_empty() {
183                body["tools"] = Value::Array(self.build_tools_payload(tools));
184            }
185        }
186
187        let resp = send_with_retry(
188            client
189                .post(format!("{}/chat/completions", self.base_url))
190                .header("Authorization", format!("Bearer {}", self.api_key))
191                .header("Content-Type", "application/json")
192                .json(&body),
193            self.name(),
194        )
195        .await?;
196
197        if !resp.status().is_success() {
198            let status = resp.status();
199            let text = resp.text().await.unwrap_or_default();
200            anyhow::bail!(
201                "{} API error {}: {}",
202                self.provider_name,
203                status,
204                truncate_chars(&text, 200)
205            );
206        }
207
208        let data: Value = resp.json().await?;
209        let choice = &data["choices"][0];
210
211        let text = choice["message"]["content"].as_str().map(String::from);
212
213        let tool_calls = choice["message"]["tool_calls"]
214            .as_array()
215            .map(|calls| {
216                calls
217                    .iter()
218                    .map(|tc| ToolCall {
219                        id: tc["id"].as_str().unwrap_or("").to_string(),
220                        name: tc["function"]["name"].as_str().unwrap_or("").to_string(),
221                        arguments: tc["function"]["arguments"]
222                            .as_str()
223                            .unwrap_or("{}")
224                            .to_string(),
225                    })
226                    .collect()
227            })
228            .unwrap_or_default();
229
230        let usage = data["usage"].as_object().map(|u| Usage {
231            input_tokens: u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
232            output_tokens: u
233                .get("completion_tokens")
234                .and_then(|v| v.as_u64())
235                .unwrap_or(0) as u32,
236        });
237
238        Ok(ChatResponse {
239            text,
240            tool_calls,
241            usage,
242        })
243    }
244}