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 temperature: f64,
129}
130
131#[derive(Debug, serde::Serialize)]
132struct Message {
133 role: String,
134 content: String,
135}
136
137#[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 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 let _ = self.client.get(format!("{}/models", BASE_URL)).send().await;
306 Ok(())
307 }
308}
309
310pub mod models {
312 pub const GPT_4O: &str = "openai/gpt-4o";
314 pub const GPT_4O_MINI: &str = "openai/gpt-4o-mini";
316 pub const GPT_4_TURBO: &str = "openai/gpt-4-turbo";
318 pub const CLAUDE_3_5_SONNET: &str = "anthropic/claude-3.5-sonnet";
320 pub const LLAMA_3_1_70B: &str = "meta-llama/llama-3.1-70b-instruct";
322 pub const LLAMA_3_1_8B: &str = "meta-llama/llama-3.1-8b-instruct";
324 pub const MISTRAL_LARGE: &str = "mistralai/mistral-large";
326 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 }
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}