1use serde_json::json;
5
6#[derive(Debug, Clone)]
8pub enum LlmResponse {
9 Text(String),
10 ToolUse(Vec<ToolCall>),
11}
12
13#[derive(Debug, Clone)]
15pub struct ToolCall {
16 pub id: String,
17 pub name: String,
18 pub input: serde_json::Value,
19}
20
21pub struct LlmClient {
23 pub provider: String,
24 pub model: String,
25 pub api_key: String,
26 pub system_prompt: Option<String>,
27 pub temperature: f64,
28 pub max_tokens: u32,
29 pub base_url: Option<String>,
30}
31
32impl LlmClient {
33 pub fn new(
35 provider: &str,
36 model: &str,
37 api_key: Option<&str>,
38 system_prompt: Option<&str>,
39 temperature: Option<f64>,
40 max_tokens: Option<u32>,
41 ) -> Result<Self, String> {
42 let resolved_key = match api_key {
43 Some(k) if !k.is_empty() => k.to_string(),
44 _ => resolve_api_key(provider)?,
45 };
46
47 Ok(LlmClient {
48 provider: provider.to_string(),
49 model: model.to_string(),
50 api_key: resolved_key,
51 system_prompt: system_prompt.map(|s| s.to_string()),
52 temperature: temperature.unwrap_or(0.7),
53 max_tokens: max_tokens.unwrap_or(1024),
54 base_url: std::env::var("TL_LLM_BASE_URL")
57 .ok()
58 .filter(|s| !s.is_empty()),
59 })
60 }
61}
62
63fn resolve_api_key(provider: &str) -> Result<String, String> {
65 if let Ok(key) = std::env::var("TL_LLM_KEY") {
67 return Ok(key);
68 }
69
70 let var_name = if provider.starts_with("claude") || provider == "anthropic" {
71 "TL_ANTHROPIC_KEY"
72 } else if provider.starts_with("gpt") || provider == "openai" {
73 "TL_OPENAI_KEY"
74 } else {
75 return std::env::var("TL_LLM_KEY").map_err(|_| {
77 format!(
78 "API key not found for provider '{provider}'. Set TL_LLM_KEY, TL_ANTHROPIC_KEY, or TL_OPENAI_KEY."
79 )
80 });
81 };
82
83 std::env::var(var_name).map_err(|_| {
84 format!(
85 "API key not found. Set the {var_name} environment variable or pass api_key parameter."
86 )
87 })
88}
89
90fn detect_provider(model: &str) -> &str {
92 if model.starts_with("claude") {
93 "anthropic"
94 } else {
95 "openai"
96 }
97}
98
99pub fn complete(
101 prompt: &str,
102 model: Option<&str>,
103 temperature: Option<f64>,
104 max_tokens: Option<u32>,
105) -> Result<String, String> {
106 let model = model.unwrap_or("claude-sonnet-4-20250514");
107 let provider = detect_provider(model);
108
109 let client = LlmClient::new(provider, model, None, None, temperature, max_tokens)?;
110 do_complete(&client, prompt)
111}
112
113pub fn chat(
115 model: &str,
116 system: Option<&str>,
117 messages: &[(String, String)],
118) -> Result<String, String> {
119 let provider = detect_provider(model);
120
121 let client = LlmClient::new(provider, model, None, system, None, None)?;
122 do_chat(&client, messages)
123}
124
125pub fn chat_with_tools(
127 model: &str,
128 system: Option<&str>,
129 messages: &[serde_json::Value],
130 tools: &[serde_json::Value],
131 base_url: Option<&str>,
132 api_key: Option<&str>,
133 output_format: Option<&str>,
134) -> Result<LlmResponse, String> {
135 let provider = detect_provider(model);
136
137 let resolved_key = match api_key {
139 Some(k) if !k.is_empty() => k.to_string(),
140 _ => resolve_api_key(provider)?,
141 };
142
143 let effective_base_url = base_url
145 .map(|s| s.to_string())
146 .or_else(|| std::env::var("TL_LLM_BASE_URL").ok());
147
148 let http = reqwest::blocking::Client::new();
149
150 let use_anthropic = provider == "anthropic" && effective_base_url.is_none();
152
153 let max_retries = 3u32;
155 let mut last_err = String::new();
156 for attempt in 0..=max_retries {
157 let result = if use_anthropic {
158 call_anthropic(&http, model, system, messages, tools, &resolved_key)
159 } else {
160 let url = effective_base_url
161 .clone()
162 .unwrap_or_else(|| "https://api.openai.com/v1".to_string());
163 call_openai(
164 &http,
165 model,
166 system,
167 messages,
168 tools,
169 &resolved_key,
170 &url,
171 output_format,
172 )
173 };
174 match result {
175 Ok(resp) => return Ok(resp),
176 Err(e) => {
177 let is_transient = e.contains("429")
178 || e.contains("500")
179 || e.contains("502")
180 || e.contains("503")
181 || e.contains("rate limit")
182 || e.contains("overloaded");
183 if is_transient && attempt < max_retries {
184 let delay_ms = 1000 * 2u64.pow(attempt); std::thread::sleep(std::time::Duration::from_millis(delay_ms));
186 last_err = e;
187 continue;
188 }
189 return Err(e);
190 }
191 }
192 }
193 Err(last_err)
194}
195
196pub fn format_tool_result_messages(
198 provider: &str,
199 tool_calls: &[ToolCall],
200 results: &[(String, String)],
201) -> Vec<serde_json::Value> {
202 let use_anthropic = provider == "anthropic";
203
204 if use_anthropic {
205 let content: Vec<serde_json::Value> = tool_calls
207 .iter()
208 .zip(results.iter())
209 .map(|(tc, (_name, result))| {
210 json!({
211 "type": "tool_result",
212 "tool_use_id": tc.id,
213 "content": result
214 })
215 })
216 .collect();
217 vec![json!({"role": "user", "content": content})]
218 } else {
219 tool_calls
221 .iter()
222 .zip(results.iter())
223 .map(|(tc, (_name, result))| {
224 json!({
225 "role": "tool",
226 "tool_call_id": tc.id,
227 "content": result
228 })
229 })
230 .collect()
231 }
232}
233
234fn call_anthropic(
237 http: &reqwest::blocking::Client,
238 model: &str,
239 system: Option<&str>,
240 messages: &[serde_json::Value],
241 tools: &[serde_json::Value],
242 api_key: &str,
243) -> Result<LlmResponse, String> {
244 let mut body = json!({
245 "model": model,
246 "max_tokens": 4096,
247 "messages": messages,
248 });
249
250 if let Some(sys) = system {
251 body["system"] = json!(sys);
252 }
253
254 if !tools.is_empty() {
255 let anthropic_tools: Vec<serde_json::Value> = tools
257 .iter()
258 .filter_map(|t| {
259 let func = t.get("function")?;
260 Some(json!({
261 "name": func["name"],
262 "description": func["description"],
263 "input_schema": func["parameters"]
264 }))
265 })
266 .collect();
267 body["tools"] = json!(anthropic_tools);
268 }
269
270 let resp = http
271 .post("https://api.anthropic.com/v1/messages")
272 .header("x-api-key", api_key)
273 .header("anthropic-version", "2023-06-01")
274 .header("content-type", "application/json")
275 .json(&body)
276 .send()
277 .map_err(|e| format!("Request failed: {e}"))?;
278
279 if !resp.status().is_success() {
280 let status = resp.status();
281 let body = resp.text().unwrap_or_default();
282 return Err(format!("Anthropic API error ({status}): {body}"));
283 }
284
285 let json: serde_json::Value = resp
286 .json()
287 .map_err(|e| format!("Failed to parse response: {e}"))?;
288
289 parse_anthropic_response(&json)
290}
291
292fn parse_anthropic_response(json: &serde_json::Value) -> Result<LlmResponse, String> {
293 let content = json["content"]
294 .as_array()
295 .ok_or("No content in Anthropic response")?;
296
297 let mut tool_calls = Vec::new();
298 let mut text_parts = Vec::new();
299
300 for block in content {
301 match block["type"].as_str() {
302 Some("tool_use") => {
303 tool_calls.push(ToolCall {
304 id: block["id"].as_str().unwrap_or("").to_string(),
305 name: block["name"].as_str().unwrap_or("").to_string(),
306 input: block["input"].clone(),
307 });
308 }
309 Some("text") => {
310 if let Some(t) = block["text"].as_str() {
311 text_parts.push(t.to_string());
312 }
313 }
314 _ => {}
315 }
316 }
317
318 if !tool_calls.is_empty() {
319 Ok(LlmResponse::ToolUse(tool_calls))
320 } else {
321 Ok(LlmResponse::Text(text_parts.join("")))
322 }
323}
324
325#[allow(clippy::too_many_arguments)]
328fn call_openai(
329 http: &reqwest::blocking::Client,
330 model: &str,
331 system: Option<&str>,
332 messages: &[serde_json::Value],
333 tools: &[serde_json::Value],
334 api_key: &str,
335 base_url: &str,
336 output_format: Option<&str>,
337) -> Result<LlmResponse, String> {
338 let mut msgs: Vec<serde_json::Value> = Vec::new();
339 if let Some(sys) = system {
340 msgs.push(json!({"role": "system", "content": sys}));
341 }
342 msgs.extend_from_slice(messages);
343
344 let mut body = json!({
345 "model": model,
346 "messages": msgs,
347 });
348
349 if !tools.is_empty() {
350 body["tools"] = json!(tools);
351 }
352
353 if output_format == Some("json") {
355 body["response_format"] = json!({"type": "json_object"});
356 }
357
358 let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
359
360 let resp = http
361 .post(&url)
362 .header("Authorization", format!("Bearer {api_key}"))
363 .header("content-type", "application/json")
364 .json(&body)
365 .send()
366 .map_err(|e| format!("Request failed: {e}"))?;
367
368 if !resp.status().is_success() {
369 let status = resp.status();
370 let body = resp.text().unwrap_or_default();
371 return Err(format!("OpenAI API error ({status}): {body}"));
372 }
373
374 let json: serde_json::Value = resp
375 .json()
376 .map_err(|e| format!("Failed to parse response: {e}"))?;
377
378 parse_openai_response(&json)
379}
380
381fn parse_openai_response(json: &serde_json::Value) -> Result<LlmResponse, String> {
382 let message = &json["choices"][0]["message"];
383
384 if let Some(tool_calls_arr) = message["tool_calls"].as_array()
386 && !tool_calls_arr.is_empty()
387 {
388 let tool_calls: Vec<ToolCall> = tool_calls_arr
389 .iter()
390 .filter_map(|tc| {
391 let func = tc.get("function")?;
392 let input: serde_json::Value = func["arguments"]
393 .as_str()
394 .and_then(|s| serde_json::from_str(s).ok())
395 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
396 Some(ToolCall {
397 id: tc["id"].as_str().unwrap_or("").to_string(),
398 name: func["name"].as_str().unwrap_or("").to_string(),
399 input,
400 })
401 })
402 .collect();
403 return Ok(LlmResponse::ToolUse(tool_calls));
404 }
405
406 message["content"]
408 .as_str()
409 .map(|s| LlmResponse::Text(s.to_string()))
410 .ok_or_else(|| "No content in OpenAI response".to_string())
411}
412
413pub fn stream_chat(
416 model: &str,
417 system: Option<&str>,
418 messages: &[serde_json::Value],
419 base_url: Option<&str>,
420 api_key: Option<&str>,
421) -> Result<StreamReader, String> {
422 let provider = detect_provider(model);
423 let resolved_key = match api_key {
424 Some(k) if !k.is_empty() => k.to_string(),
425 _ => resolve_api_key(provider)?,
426 };
427 let effective_base_url = base_url
428 .map(|s| s.to_string())
429 .or_else(|| std::env::var("TL_LLM_BASE_URL").ok());
430
431 let http = reqwest::blocking::Client::new();
432 let use_anthropic = provider == "anthropic" && effective_base_url.is_none();
433
434 if use_anthropic {
435 stream_anthropic(&http, model, system, messages, &resolved_key)
436 } else {
437 let url = effective_base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
438 stream_openai(&http, model, system, messages, &resolved_key, &url)
439 }
440}
441
442pub struct StreamReader {
444 lines: std::io::BufReader<reqwest::blocking::Response>,
445 is_anthropic: bool,
446 done: bool,
447}
448
449impl StreamReader {
450 pub fn next_chunk(&mut self) -> Result<Option<String>, String> {
452 use std::io::BufRead;
453 if self.done {
454 return Ok(None);
455 }
456 loop {
457 let mut line = String::new();
458 match self.lines.read_line(&mut line) {
459 Ok(0) => {
460 self.done = true;
461 return Ok(None);
462 }
463 Ok(_) => {}
464 Err(e) => return Err(format!("Stream read error: {e}")),
465 }
466 let line = line.trim();
467 if line.is_empty() {
468 continue;
469 }
470 if !line.starts_with("data: ") {
471 continue;
472 }
473 let data = &line[6..];
474 if data == "[DONE]" {
475 self.done = true;
476 return Ok(None);
477 }
478
479 let json: serde_json::Value = match serde_json::from_str(data) {
480 Ok(v) => v,
481 Err(_) => continue,
482 };
483
484 if self.is_anthropic {
485 if json["type"].as_str() == Some("content_block_delta") {
487 if let Some(text) = json["delta"]["text"].as_str()
488 && !text.is_empty()
489 {
490 return Ok(Some(text.to_string()));
491 }
492 } else if json["type"].as_str() == Some("message_stop") {
493 self.done = true;
494 return Ok(None);
495 }
496 } else {
497 if let Some(content) = json["choices"][0]["delta"]["content"].as_str()
499 && !content.is_empty()
500 {
501 return Ok(Some(content.to_string()));
502 }
503 if json["choices"][0]["finish_reason"].as_str().is_some() {
505 self.done = true;
506 return Ok(None);
507 }
508 }
509 }
510 }
511}
512
513fn stream_openai(
514 http: &reqwest::blocking::Client,
515 model: &str,
516 system: Option<&str>,
517 messages: &[serde_json::Value],
518 api_key: &str,
519 base_url: &str,
520) -> Result<StreamReader, String> {
521 let mut msgs: Vec<serde_json::Value> = Vec::new();
522 if let Some(sys) = system {
523 msgs.push(json!({"role": "system", "content": sys}));
524 }
525 msgs.extend_from_slice(messages);
526
527 let body = json!({
528 "model": model,
529 "messages": msgs,
530 "stream": true,
531 });
532
533 let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
534 let resp = http
535 .post(&url)
536 .header("Authorization", format!("Bearer {api_key}"))
537 .header("content-type", "application/json")
538 .json(&body)
539 .send()
540 .map_err(|e| format!("Stream request failed: {e}"))?;
541
542 if !resp.status().is_success() {
543 let status = resp.status();
544 let body = resp.text().unwrap_or_default();
545 return Err(format!("OpenAI streaming API error ({status}): {body}"));
546 }
547
548 Ok(StreamReader {
549 lines: std::io::BufReader::new(resp),
550 is_anthropic: false,
551 done: false,
552 })
553}
554
555fn stream_anthropic(
556 http: &reqwest::blocking::Client,
557 model: &str,
558 system: Option<&str>,
559 messages: &[serde_json::Value],
560 api_key: &str,
561) -> Result<StreamReader, String> {
562 let mut body = json!({
563 "model": model,
564 "max_tokens": 4096,
565 "messages": messages,
566 "stream": true,
567 });
568 if let Some(sys) = system {
569 body["system"] = json!(sys);
570 }
571
572 let resp = http
573 .post("https://api.anthropic.com/v1/messages")
574 .header("x-api-key", api_key)
575 .header("anthropic-version", "2023-06-01")
576 .header("content-type", "application/json")
577 .json(&body)
578 .send()
579 .map_err(|e| format!("Stream request failed: {e}"))?;
580
581 if !resp.status().is_success() {
582 let status = resp.status();
583 let body = resp.text().unwrap_or_default();
584 return Err(format!("Anthropic streaming API error ({status}): {body}"));
585 }
586
587 Ok(StreamReader {
588 lines: std::io::BufReader::new(resp),
589 is_anthropic: true,
590 done: false,
591 })
592}
593
594fn do_complete(client: &LlmClient, prompt: &str) -> Result<String, String> {
597 let http = reqwest::blocking::Client::new();
598 let mut last_err = String::new();
599
600 let use_anthropic = (client.provider == "anthropic" || client.model.starts_with("claude"))
602 && client.base_url.is_none();
603 for attempt in 0..3 {
604 let result = if use_anthropic {
605 complete_anthropic(&http, client, prompt)
606 } else {
607 complete_openai(&http, client, prompt)
608 };
609
610 match result {
611 Ok(text) => return Ok(text),
612 Err(e) => {
613 last_err = e;
614 if attempt < 2 {
615 std::thread::sleep(std::time::Duration::from_millis(
616 500 * (attempt as u64 + 1),
617 ));
618 }
619 }
620 }
621 }
622
623 Err(format!("LLM request failed after 3 attempts: {last_err}"))
624}
625
626fn do_chat(client: &LlmClient, messages: &[(String, String)]) -> Result<String, String> {
627 let http = reqwest::blocking::Client::new();
628
629 let use_anthropic = (client.provider == "anthropic" || client.model.starts_with("claude"))
630 && client.base_url.is_none();
631 if use_anthropic {
632 chat_anthropic(&http, client, messages)
633 } else {
634 chat_openai(&http, client, messages)
635 }
636}
637
638fn complete_anthropic(
639 http: &reqwest::blocking::Client,
640 client: &LlmClient,
641 prompt: &str,
642) -> Result<String, String> {
643 let body = json!({
644 "model": client.model,
645 "max_tokens": client.max_tokens,
646 "temperature": client.temperature,
647 "messages": [{"role": "user", "content": prompt}],
648 });
649
650 let resp = http
651 .post("https://api.anthropic.com/v1/messages")
652 .header("x-api-key", &client.api_key)
653 .header("anthropic-version", "2023-06-01")
654 .header("content-type", "application/json")
655 .json(&body)
656 .send()
657 .map_err(|e| format!("Request failed: {e}"))?;
658
659 if !resp.status().is_success() {
660 let status = resp.status();
661 let body = resp.text().unwrap_or_default();
662 return Err(format!("Anthropic API error ({status}): {body}"));
663 }
664
665 let json: serde_json::Value = resp
666 .json()
667 .map_err(|e| format!("Failed to parse response: {e}"))?;
668
669 json["content"][0]["text"]
670 .as_str()
671 .map(|s| s.to_string())
672 .ok_or_else(|| "No text in Anthropic response".to_string())
673}
674
675fn complete_openai(
676 http: &reqwest::blocking::Client,
677 client: &LlmClient,
678 prompt: &str,
679) -> Result<String, String> {
680 let body = json!({
681 "model": client.model,
682 "max_tokens": client.max_tokens,
683 "temperature": client.temperature,
684 "messages": [{"role": "user", "content": prompt}],
685 });
686
687 let base = client
688 .base_url
689 .as_deref()
690 .unwrap_or("https://api.openai.com/v1");
691 let url = format!("{}/chat/completions", base.trim_end_matches('/'));
692 let resp = http
693 .post(&url)
694 .header("Authorization", format!("Bearer {}", client.api_key))
695 .json(&body)
696 .send()
697 .map_err(|e| format!("Request failed: {e}"))?;
698
699 if !resp.status().is_success() {
700 let status = resp.status();
701 let body = resp.text().unwrap_or_default();
702 return Err(format!("OpenAI API error ({status}): {body}"));
703 }
704
705 let json: serde_json::Value = resp
706 .json()
707 .map_err(|e| format!("Failed to parse response: {e}"))?;
708
709 json["choices"][0]["message"]["content"]
710 .as_str()
711 .map(|s| s.to_string())
712 .ok_or_else(|| "No content in OpenAI response".to_string())
713}
714
715fn chat_anthropic(
716 http: &reqwest::blocking::Client,
717 client: &LlmClient,
718 messages: &[(String, String)],
719) -> Result<String, String> {
720 let msgs: Vec<serde_json::Value> = messages
721 .iter()
722 .map(|(role, content)| json!({"role": role, "content": content}))
723 .collect();
724
725 let mut body = json!({
726 "model": client.model,
727 "max_tokens": client.max_tokens,
728 "temperature": client.temperature,
729 "messages": msgs,
730 });
731
732 if let Some(ref system) = client.system_prompt {
733 body["system"] = json!(system);
734 }
735
736 let resp = http
737 .post("https://api.anthropic.com/v1/messages")
738 .header("x-api-key", &client.api_key)
739 .header("anthropic-version", "2023-06-01")
740 .header("content-type", "application/json")
741 .json(&body)
742 .send()
743 .map_err(|e| format!("Request failed: {e}"))?;
744
745 if !resp.status().is_success() {
746 let status = resp.status();
747 let body = resp.text().unwrap_or_default();
748 return Err(format!("Anthropic API error ({status}): {body}"));
749 }
750
751 let json: serde_json::Value = resp
752 .json()
753 .map_err(|e| format!("Failed to parse response: {e}"))?;
754
755 json["content"][0]["text"]
756 .as_str()
757 .map(|s| s.to_string())
758 .ok_or_else(|| "No text in Anthropic response".to_string())
759}
760
761fn chat_openai(
762 http: &reqwest::blocking::Client,
763 client: &LlmClient,
764 messages: &[(String, String)],
765) -> Result<String, String> {
766 let mut msgs: Vec<serde_json::Value> = Vec::new();
767 if let Some(ref system) = client.system_prompt {
768 msgs.push(json!({"role": "system", "content": system}));
769 }
770 for (role, content) in messages {
771 msgs.push(json!({"role": role, "content": content}));
772 }
773
774 let body = json!({
775 "model": client.model,
776 "max_tokens": client.max_tokens,
777 "temperature": client.temperature,
778 "messages": msgs,
779 });
780
781 let base = client
782 .base_url
783 .as_deref()
784 .unwrap_or("https://api.openai.com/v1");
785 let url = format!("{}/chat/completions", base.trim_end_matches('/'));
786 let resp = http
787 .post(&url)
788 .header("Authorization", format!("Bearer {}", client.api_key))
789 .json(&body)
790 .send()
791 .map_err(|e| format!("Request failed: {e}"))?;
792
793 if !resp.status().is_success() {
794 let status = resp.status();
795 let body = resp.text().unwrap_or_default();
796 return Err(format!("OpenAI API error ({status}): {body}"));
797 }
798
799 let json: serde_json::Value = resp
800 .json()
801 .map_err(|e| format!("Failed to parse response: {e}"))?;
802
803 json["choices"][0]["message"]["content"]
804 .as_str()
805 .map(|s| s.to_string())
806 .ok_or_else(|| "No content in OpenAI response".to_string())
807}