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    /// `[providers.models.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    #[serde(skip_serializing_if = "Option::is_none")]
129    temperature: Option<f64>,
130}
131
132#[derive(Debug, serde::Serialize)]
133struct Message {
134    role: String,
135    content: String,
136}
137
138/// Response from chat completions API
139#[derive(Debug, Deserialize)]
140struct ChatResponse {
141    choices: Vec<Choice>,
142}
143
144#[derive(Debug, Deserialize)]
145struct Choice {
146    message: ResponseMessage,
147}
148
149#[derive(Debug, Deserialize)]
150struct ResponseMessage {
151    content: String,
152}
153
154#[async_trait]
155impl ModelProvider for TelnyxModelProvider {
156    // ── ModelProvider-family defaults ──
157    fn default_base_url(&self) -> Option<&str> {
158        Some(BASE_URL)
159    }
160
161    async fn chat_with_system(
162        &self,
163        system_prompt: Option<&str>,
164        message: &str,
165        model: &str,
166        temperature: Option<f64>,
167    ) -> anyhow::Result<String> {
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 quickstart --model-provider telnyx --api-key <key>`.",
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 api_key = self.api_key.as_ref().ok_or_else(|| {
242            ::zeroclaw_log::record!(
243                ERROR,
244                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
245                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
246                    .with_attrs(::serde_json::json!({"missing": "api_key"})),
247                "telnyx: API key not configured"
248            );
249            anyhow::Error::msg(
250                "Telnyx API key not set. Set TELNYX_API_KEY environment variable or run `zeroclaw quickstart --model-provider telnyx --api-key <key>`.",
251            )
252        })?;
253
254        let api_messages: Vec<Message> = messages
255            .iter()
256            .map(|m| Message {
257                role: m.role.clone(),
258                content: m.content.clone(),
259            })
260            .collect();
261
262        let request = ChatRequest {
263            model: model.to_string(),
264            messages: api_messages,
265            temperature,
266        };
267
268        let response = self
269            .client
270            .post(self.chat_url())
271            .header("Authorization", format!("Bearer {}", api_key))
272            .header("Content-Type", "application/json")
273            .json(&request)
274            .send()
275            .await?;
276
277        if !response.status().is_success() {
278            let status = response.status();
279            let error = response.text().await?;
280            let sanitized = super::sanitize_api_error(&error);
281            anyhow::bail!("Telnyx API error ({}): {}", status, sanitized);
282        }
283
284        let chat_response: ChatResponse = response.json().await?;
285
286        chat_response
287            .choices
288            .into_iter()
289            .next()
290            .map(|c| c.message.content)
291            .ok_or_else(|| {
292                ::zeroclaw_log::record!(
293                    ERROR,
294                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
295                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
296                    "telnyx: empty choices in response"
297                );
298                anyhow::Error::msg("No response from Telnyx")
299            })
300    }
301
302    async fn warmup(&self) -> anyhow::Result<()> {
303        // Pre-warm the connection pool
304        let _ = self.client.get(format!("{}/models", BASE_URL)).send().await;
305        Ok(())
306    }
307}
308
309/// Popular Telnyx AI models for easy reference.
310pub mod models {
311    /// OpenAI GPT-4o (recommended for most tasks)
312    pub const GPT_4O: &str = "openai/gpt-4o";
313    /// OpenAI GPT-4o Mini (fast and cost-effective)
314    pub const GPT_4O_MINI: &str = "openai/gpt-4o-mini";
315    /// OpenAI GPT-4 Turbo
316    pub const GPT_4_TURBO: &str = "openai/gpt-4-turbo";
317    /// Anthropic Claude 3.5 Sonnet (via Telnyx proxy)
318    pub const CLAUDE_3_5_SONNET: &str = "anthropic/claude-3.5-sonnet";
319    /// Meta Llama 3.1 70B Instruct
320    pub const LLAMA_3_1_70B: &str = "meta-llama/llama-3.1-70b-instruct";
321    /// Meta Llama 3.1 8B Instruct (fast)
322    pub const LLAMA_3_1_8B: &str = "meta-llama/llama-3.1-8b-instruct";
323    /// Mistral Large
324    pub const MISTRAL_LARGE: &str = "mistralai/mistral-large";
325    /// Mistral Small (fast)
326    pub const MISTRAL_SMALL: &str = "mistralai/mistral-small";
327}
328
329impl ::zeroclaw_api::attribution::Attributable for TelnyxModelProvider {
330    fn role(&self) -> ::zeroclaw_api::attribution::Role {
331        ::zeroclaw_api::attribution::Role::Provider(
332            ::zeroclaw_api::attribution::ProviderKind::Model(
333                ::zeroclaw_api::attribution::ModelProviderKind::Telnyx,
334            ),
335        )
336    }
337    fn alias(&self) -> &str {
338        &self.alias
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn creates_provider_with_key() {
348        let model_provider = TelnyxModelProvider::new("test", Some("test-key"));
349        assert!(model_provider.api_key.is_some());
350    }
351
352    #[test]
353    fn creates_provider_without_key() {
354        let _provider = TelnyxModelProvider::new("test", None);
355        // Will be None if env vars not set
356    }
357
358    #[test]
359    fn model_constants_are_valid() {
360        assert!(models::GPT_4O.starts_with("openai/"));
361        assert!(models::CLAUDE_3_5_SONNET.starts_with("anthropic/"));
362        assert!(models::LLAMA_3_1_70B.starts_with("meta-llama/"));
363        assert!(models::MISTRAL_LARGE.starts_with("mistralai/"));
364    }
365
366    #[test]
367    fn resolve_key_from_parameter() {
368        let key = resolve_telnyx_api_key(Some("direct-key"));
369        assert_eq!(key, Some("direct-key".to_string()));
370    }
371
372    #[test]
373    fn resolve_key_trims_whitespace() {
374        let key = resolve_telnyx_api_key(Some("  spaced-key  "));
375        assert_eq!(key, Some("spaced-key".to_string()));
376    }
377
378    #[test]
379    fn models_response_deserializes() {
380        let json = r#"{
381            "data": [
382                {"id": "openai/gpt-4o"},
383                {"id": "anthropic/claude-3.5-sonnet"}
384            ]
385        }"#;
386
387        let response: ModelsResponse = serde_json::from_str(json).unwrap();
388        assert_eq!(response.data.len(), 2);
389        assert_eq!(response.data[0].id, "openai/gpt-4o");
390    }
391
392    #[test]
393    fn chat_request_serializes() {
394        let req = ChatRequest {
395            model: "openai/gpt-4o".to_string(),
396            messages: vec![
397                Message {
398                    role: "system".to_string(),
399                    content: "You are helpful.".to_string(),
400                },
401                Message {
402                    role: "user".to_string(),
403                    content: "Hello".to_string(),
404                },
405            ],
406            temperature: Some(0.7),
407        };
408
409        let json = serde_json::to_string(&req).unwrap();
410        assert!(json.contains("openai/gpt-4o"));
411        assert!(json.contains("system"));
412        assert!(json.contains("user"));
413    }
414
415    #[test]
416    fn chat_response_deserializes() {
417        let json = r#"{"choices":[{"message":{"content":"Hello from Telnyx!"}}]}"#;
418        let resp: ChatResponse = serde_json::from_str(json).unwrap();
419        assert_eq!(resp.choices[0].message.content, "Hello from Telnyx!");
420    }
421}