Skip to main content

zeroclaw_providers/
telnyx.rs

1//! Telnyx AI inference model_provider.
2//!
3//! Telnyx provides AI inference through an OpenAI-compatible API at
4//! <https://api.telnyx.com/v2/ai> with access to 53+ models including
5//! GPT-4o, Claude, Llama, Mistral, and more.
6//!
7//! # Configuration
8//!
9//! Set the `TELNYX_API_KEY` environment variable or configure in `config.toml`:
10//!
11//! ```toml
12//! default_model_provider = "telnyx"
13//! default_model = "openai/gpt-4o"
14//! ```
15
16use crate::traits::{ChatMessage, ModelProvider};
17use async_trait::async_trait;
18use reqwest::Client;
19use serde::Deserialize;
20
21/// Telnyx Inference Engine public endpoint.
22pub(crate) const BASE_URL: &str = "https://api.telnyx.com/v2/ai";
23
24/// Telnyx AI inference model_provider.
25///
26/// Uses the OpenAI-compatible chat completions API at `/v2/ai/chat/completions`.
27/// Supports 53+ models including OpenAI, Anthropic (via API), Meta Llama,
28/// Mistral, and more.
29///
30/// # Example
31///
32/// ```rust,ignore
33/// use zeroclaw::providers::telnyx::TelnyxModelProvider;
34/// use zeroclaw::providers::ModelProvider;
35///
36/// let model_provider = TelnyxModelProvider::new("test", Some("your-api-key"));
37/// let response = model_provider.chat("Hello!", "openai/gpt-4o", 0.7).await?;
38/// ```
39pub struct TelnyxModelProvider {
40    /// `[model_providers.telnyx.<alias>]` config-key alias.
41    alias: String,
42    /// Telnyx API key
43    api_key: Option<String>,
44    /// HTTP client for API requests
45    client: Client,
46}
47
48impl TelnyxModelProvider {
49    /// Create a new Telnyx AI model_provider.
50    pub fn new(alias: &str, api_key: Option<&str>) -> Self {
51        let resolved_key = resolve_telnyx_api_key(api_key);
52        Self {
53            alias: alias.to_string(),
54            api_key: resolved_key,
55            client: Client::builder()
56                .timeout(std::time::Duration::from_secs(120))
57                .connect_timeout(std::time::Duration::from_secs(10))
58                .build()
59                .unwrap_or_else(|_| Client::new()),
60        }
61    }
62    /// Create a model_provider with a custom base URL (for testing or proxies).
63    pub fn with_base_url(alias: &str, api_key: Option<&str>, _base_url: &str) -> Self {
64        // Note: custom base URL support for testing
65        Self::new(alias, api_key)
66    }
67
68    /// List available models from Telnyx AI.
69    ///
70    /// Returns a list of model IDs that can be used with the chat API.
71    pub async fn list_models(&self) -> anyhow::Result<Vec<String>> {
72        let api_key = self.api_key.as_ref().ok_or_else(|| {
73            ::zeroclaw_log::record!(
74                ERROR,
75                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
76                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
77                    .with_attrs(::serde_json::json!({"missing": "api_key"})),
78                "telnyx: API key not configured"
79            );
80            anyhow::Error::msg("Telnyx API key not set. Set TELNYX_API_KEY environment variable.")
81        })?;
82
83        let response = self
84            .client
85            .get(format!("{}/models", BASE_URL))
86            .header("Authorization", format!("Bearer {}", api_key))
87            .send()
88            .await?;
89
90        if !response.status().is_success() {
91            let error = response.text().await?;
92            anyhow::bail!("Failed to list Telnyx models: {}", error);
93        }
94
95        let models_response: ModelsResponse = response.json().await?;
96        Ok(models_response.data.into_iter().map(|m| m.id).collect())
97    }
98
99    /// Build the chat completions URL
100    fn chat_url(&self) -> String {
101        format!("{}/chat/completions", BASE_URL)
102    }
103}
104
105fn resolve_telnyx_api_key(api_key: Option<&str>) -> Option<String> {
106    api_key
107        .map(str::trim)
108        .filter(|k| !k.is_empty())
109        .map(ToString::to_string)
110}
111
112/// Response from the /models endpoint
113#[derive(Debug, Deserialize)]
114struct ModelsResponse {
115    data: Vec<ModelInfo>,
116}
117
118#[derive(Debug, Deserialize)]
119struct ModelInfo {
120    id: String,
121}
122
123/// Request body for chat completions
124#[derive(Debug, serde::Serialize)]
125struct ChatRequest {
126    model: String,
127    messages: Vec<Message>,
128    temperature: f64,
129}
130
131#[derive(Debug, serde::Serialize)]
132struct Message {
133    role: String,
134    content: String,
135}
136
137/// Response from chat completions API
138#[derive(Debug, Deserialize)]
139struct ChatResponse {
140    choices: Vec<Choice>,
141}
142
143#[derive(Debug, Deserialize)]
144struct Choice {
145    message: ResponseMessage,
146}
147
148#[derive(Debug, Deserialize)]
149struct ResponseMessage {
150    content: String,
151}
152
153#[async_trait]
154impl ModelProvider for TelnyxModelProvider {
155    // ── ModelProvider-family defaults ──
156    fn default_base_url(&self) -> Option<&str> {
157        Some(BASE_URL)
158    }
159
160    async fn chat_with_system(
161        &self,
162        system_prompt: Option<&str>,
163        message: &str,
164        model: &str,
165        temperature: Option<f64>,
166    ) -> anyhow::Result<String> {
167        let temperature = temperature.unwrap_or(self.default_temperature());
168        let api_key = self.api_key.as_ref().ok_or_else(|| {
169            ::zeroclaw_log::record!(
170                ERROR,
171                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
172                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
173                    .with_attrs(::serde_json::json!({"missing": "api_key"})),
174                "telnyx: API key not configured"
175            );
176            anyhow::Error::msg(
177                "Telnyx API key not set. Set TELNYX_API_KEY environment variable or run `zeroclaw onboard`.",
178            )
179        })?;
180
181        let mut messages = Vec::new();
182
183        if let Some(sys) = system_prompt {
184            messages.push(Message {
185                role: "system".to_string(),
186                content: sys.to_string(),
187            });
188        }
189
190        messages.push(Message {
191            role: "user".to_string(),
192            content: message.to_string(),
193        });
194
195        let request = ChatRequest {
196            model: model.to_string(),
197            messages,
198            temperature,
199        };
200
201        let response = self
202            .client
203            .post(self.chat_url())
204            .header("Authorization", format!("Bearer {}", api_key))
205            .header("Content-Type", "application/json")
206            .json(&request)
207            .send()
208            .await?;
209
210        if !response.status().is_success() {
211            let status = response.status();
212            let error = response.text().await?;
213            let sanitized = super::sanitize_api_error(&error);
214            anyhow::bail!("Telnyx API error ({}): {}", status, sanitized);
215        }
216
217        let chat_response: ChatResponse = response.json().await?;
218
219        chat_response
220            .choices
221            .into_iter()
222            .next()
223            .map(|c| c.message.content)
224            .ok_or_else(|| {
225                ::zeroclaw_log::record!(
226                    ERROR,
227                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
228                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
229                    "telnyx: empty choices in response"
230                );
231                anyhow::Error::msg("No response from Telnyx")
232            })
233    }
234
235    async fn chat_with_history(
236        &self,
237        messages: &[ChatMessage],
238        model: &str,
239        temperature: Option<f64>,
240    ) -> anyhow::Result<String> {
241        let temperature = temperature.unwrap_or(self.default_temperature());
242        let api_key = self.api_key.as_ref().ok_or_else(|| {
243            ::zeroclaw_log::record!(
244                ERROR,
245                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
246                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
247                    .with_attrs(::serde_json::json!({"missing": "api_key"})),
248                "telnyx: API key not configured"
249            );
250            anyhow::Error::msg(
251                "Telnyx API key not set. Set TELNYX_API_KEY environment variable or run `zeroclaw onboard`.",
252            )
253        })?;
254
255        let api_messages: Vec<Message> = messages
256            .iter()
257            .map(|m| Message {
258                role: m.role.clone(),
259                content: m.content.clone(),
260            })
261            .collect();
262
263        let request = ChatRequest {
264            model: model.to_string(),
265            messages: api_messages,
266            temperature,
267        };
268
269        let response = self
270            .client
271            .post(self.chat_url())
272            .header("Authorization", format!("Bearer {}", api_key))
273            .header("Content-Type", "application/json")
274            .json(&request)
275            .send()
276            .await?;
277
278        if !response.status().is_success() {
279            let status = response.status();
280            let error = response.text().await?;
281            let sanitized = super::sanitize_api_error(&error);
282            anyhow::bail!("Telnyx API error ({}): {}", status, sanitized);
283        }
284
285        let chat_response: ChatResponse = response.json().await?;
286
287        chat_response
288            .choices
289            .into_iter()
290            .next()
291            .map(|c| c.message.content)
292            .ok_or_else(|| {
293                ::zeroclaw_log::record!(
294                    ERROR,
295                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
296                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
297                    "telnyx: empty choices in response"
298                );
299                anyhow::Error::msg("No response from Telnyx")
300            })
301    }
302
303    async fn warmup(&self) -> anyhow::Result<()> {
304        // Pre-warm the connection pool
305        let _ = self.client.get(format!("{}/models", BASE_URL)).send().await;
306        Ok(())
307    }
308}
309
310/// Popular Telnyx AI models for easy reference.
311pub mod models {
312    /// OpenAI GPT-4o (recommended for most tasks)
313    pub const GPT_4O: &str = "openai/gpt-4o";
314    /// OpenAI GPT-4o Mini (fast and cost-effective)
315    pub const GPT_4O_MINI: &str = "openai/gpt-4o-mini";
316    /// OpenAI GPT-4 Turbo
317    pub const GPT_4_TURBO: &str = "openai/gpt-4-turbo";
318    /// Anthropic Claude 3.5 Sonnet (via Telnyx proxy)
319    pub const CLAUDE_3_5_SONNET: &str = "anthropic/claude-3.5-sonnet";
320    /// Meta Llama 3.1 70B Instruct
321    pub const LLAMA_3_1_70B: &str = "meta-llama/llama-3.1-70b-instruct";
322    /// Meta Llama 3.1 8B Instruct (fast)
323    pub const LLAMA_3_1_8B: &str = "meta-llama/llama-3.1-8b-instruct";
324    /// Mistral Large
325    pub const MISTRAL_LARGE: &str = "mistralai/mistral-large";
326    /// Mistral Small (fast)
327    pub const MISTRAL_SMALL: &str = "mistralai/mistral-small";
328}
329
330impl ::zeroclaw_api::attribution::Attributable for TelnyxModelProvider {
331    fn role(&self) -> ::zeroclaw_api::attribution::Role {
332        ::zeroclaw_api::attribution::Role::Provider(
333            ::zeroclaw_api::attribution::ProviderKind::Model(
334                ::zeroclaw_api::attribution::ModelProviderKind::Telnyx,
335            ),
336        )
337    }
338    fn alias(&self) -> &str {
339        &self.alias
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn creates_provider_with_key() {
349        let model_provider = TelnyxModelProvider::new("test", Some("test-key"));
350        assert!(model_provider.api_key.is_some());
351    }
352
353    #[test]
354    fn creates_provider_without_key() {
355        let _provider = TelnyxModelProvider::new("test", None);
356        // Will be None if env vars not set
357    }
358
359    #[test]
360    fn model_constants_are_valid() {
361        assert!(models::GPT_4O.starts_with("openai/"));
362        assert!(models::CLAUDE_3_5_SONNET.starts_with("anthropic/"));
363        assert!(models::LLAMA_3_1_70B.starts_with("meta-llama/"));
364        assert!(models::MISTRAL_LARGE.starts_with("mistralai/"));
365    }
366
367    #[test]
368    fn resolve_key_from_parameter() {
369        let key = resolve_telnyx_api_key(Some("direct-key"));
370        assert_eq!(key, Some("direct-key".to_string()));
371    }
372
373    #[test]
374    fn resolve_key_trims_whitespace() {
375        let key = resolve_telnyx_api_key(Some("  spaced-key  "));
376        assert_eq!(key, Some("spaced-key".to_string()));
377    }
378
379    #[test]
380    fn models_response_deserializes() {
381        let json = r#"{
382            "data": [
383                {"id": "openai/gpt-4o"},
384                {"id": "anthropic/claude-3.5-sonnet"}
385            ]
386        }"#;
387
388        let response: ModelsResponse = serde_json::from_str(json).unwrap();
389        assert_eq!(response.data.len(), 2);
390        assert_eq!(response.data[0].id, "openai/gpt-4o");
391    }
392
393    #[test]
394    fn chat_request_serializes() {
395        let req = ChatRequest {
396            model: "openai/gpt-4o".to_string(),
397            messages: vec![
398                Message {
399                    role: "system".to_string(),
400                    content: "You are helpful.".to_string(),
401                },
402                Message {
403                    role: "user".to_string(),
404                    content: "Hello".to_string(),
405                },
406            ],
407            temperature: 0.7,
408        };
409
410        let json = serde_json::to_string(&req).unwrap();
411        assert!(json.contains("openai/gpt-4o"));
412        assert!(json.contains("system"));
413        assert!(json.contains("user"));
414    }
415
416    #[test]
417    fn chat_response_deserializes() {
418        let json = r#"{"choices":[{"message":{"content":"Hello from Telnyx!"}}]}"#;
419        let resp: ChatResponse = serde_json::from_str(json).unwrap();
420        assert_eq!(resp.choices[0].message.content, "Hello from Telnyx!");
421    }
422}