Skip to main content

tl_ai/
llm.rs

1// ThinkingLanguage — LLM Integration
2// HTTP-based integration with Claude, OpenAI, and any OpenAI-compatible endpoint.
3
4use serde_json::json;
5
6/// Structured LLM response — either text or tool-use requests.
7#[derive(Debug, Clone)]
8pub enum LlmResponse {
9    Text(String),
10    ToolUse(Vec<ToolCall>),
11}
12
13/// A tool call requested by the model.
14#[derive(Debug, Clone)]
15pub struct ToolCall {
16    pub id: String,
17    pub name: String,
18    pub input: serde_json::Value,
19}
20
21/// LLM client configuration.
22pub 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    /// Create a new LLM client, resolving the API key from params or env vars.
34    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            // Allow targeting any OpenAI-compatible endpoint (Groq, Together,
55            // local servers, ...) via TL_LLM_BASE_URL, like chat_with_tools.
56            base_url: std::env::var("TL_LLM_BASE_URL")
57                .ok()
58                .filter(|s| !s.is_empty()),
59        })
60    }
61}
62
63/// Resolve API key from environment variables.
64fn resolve_api_key(provider: &str) -> Result<String, String> {
65    // Try generic key first
66    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        // For unknown providers, try generic key or OpenAI-compatible key
76        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
90/// Determine provider from model name.
91fn detect_provider(model: &str) -> &str {
92    if model.starts_with("claude") {
93        "anthropic"
94    } else {
95        "openai"
96    }
97}
98
99/// Single completion: send a prompt, get a response string.
100pub 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
113/// Multi-turn chat: send messages, get a response.
114pub 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
125/// Multi-turn chat with tool definitions. Returns structured LlmResponse.
126pub 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    // Resolve API key
138    let resolved_key = match api_key {
139        Some(k) if !k.is_empty() => k.to_string(),
140        _ => resolve_api_key(provider)?,
141    };
142
143    // Determine the actual base URL
144    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    // If base_url is set, always use OpenAI-compatible protocol
151    let use_anthropic = provider == "anthropic" && effective_base_url.is_none();
152
153    // Retry with exponential backoff for transient errors
154    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); // 1s, 2s, 4s
185                    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
196/// Format tool results back into messages for the next turn.
197pub 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        // Anthropic: single user message with tool_result content blocks
206        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        // OpenAI: separate tool message per result
220        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
234// --- Internal: Anthropic API with tools ---
235
236fn 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        // Convert from OpenAI tool format to Anthropic format
256        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// --- Internal: OpenAI-compatible API with tools ---
326
327#[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    // JSON mode: request structured output
354    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    // Check for tool calls
385    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    // Text response
407    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
413/// Streaming chat completion. Calls `on_chunk` with each text delta.
414/// Returns the full accumulated text.
415pub 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
442/// A streaming response reader that yields text chunks.
443pub struct StreamReader {
444    lines: std::io::BufReader<reqwest::blocking::Response>,
445    is_anthropic: bool,
446    done: bool,
447}
448
449impl StreamReader {
450    /// Read the next text chunk. Returns None when stream is done.
451    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                // Anthropic SSE: {"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}
486                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                // OpenAI SSE: {"choices":[{"delta":{"content":"..."}}]}
498                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                // Check for finish_reason
504                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
594// --- Backward-compatible internal helpers ---
595
596fn 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    // A base_url override always means an OpenAI-compatible endpoint.
601    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}