1use crate::traits::{ChatMessage, ModelProvider};
17use async_trait::async_trait;
18use reqwest::Client;
19use serde::Deserialize;
20
21pub(crate) const BASE_URL: &str = "https://api.telnyx.com/v2/ai";
23
24pub struct TelnyxModelProvider {
40 alias: String,
42 api_key: Option<String>,
44 client: Client,
46}
47
48impl TelnyxModelProvider {
49 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 pub fn with_base_url(alias: &str, api_key: Option<&str>, _base_url: &str) -> Self {
64 Self::new(alias, api_key)
66 }
67
68 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 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#[derive(Debug, Deserialize)]
114struct ModelsResponse {
115 data: Vec<ModelInfo>,
116}
117
118#[derive(Debug, Deserialize)]
119struct ModelInfo {
120 id: String,
121}
122
123#[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#[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 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 let _ = self.client.get(format!("{}/models", BASE_URL)).send().await;
305 Ok(())
306 }
307}
308
309pub mod models {
311 pub const GPT_4O: &str = "openai/gpt-4o";
313 pub const GPT_4O_MINI: &str = "openai/gpt-4o-mini";
315 pub const GPT_4_TURBO: &str = "openai/gpt-4-turbo";
317 pub const CLAUDE_3_5_SONNET: &str = "anthropic/claude-3.5-sonnet";
319 pub const LLAMA_3_1_70B: &str = "meta-llama/llama-3.1-70b-instruct";
321 pub const LLAMA_3_1_8B: &str = "meta-llama/llama-3.1-8b-instruct";
323 pub const MISTRAL_LARGE: &str = "mistralai/mistral-large";
325 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 }
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}