Skip to main content

recursive/llm/
openai.rs

1//! OpenAI-compatible chat-completions adapter.
2//!
3//! Targets the `/chat/completions` shape that OpenAI, Azure (via gateway),
4//! GLM (Zhipu), DeepSeek, Moonshot, Together, Ollama and many others speak.
5//! The only thing that varies is the base URL + model name + API key, which
6//! is all driven by config.
7
8use async_trait::async_trait;
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::time::Duration;
13
14use super::StructuredRequest;
15use super::{Completion, LlmProvider, StreamSender, TokenUsage, ToolCall, ToolSpec};
16use crate::error::{Error, Result};
17use crate::message::{Message, Role};
18
19/// Retry policy for transient failures (network timeouts, 5xx errors).
20#[derive(Debug, Clone)]
21pub struct RetryPolicy {
22    pub max_retries: usize,
23    pub initial_backoff: Duration,
24    pub max_backoff: Duration,
25}
26
27impl Default for RetryPolicy {
28    fn default() -> Self {
29        Self {
30            max_retries: 2,
31            initial_backoff: Duration::from_secs(1),
32            max_backoff: Duration::from_secs(8),
33        }
34    }
35}
36
37impl RetryPolicy {
38    /// Decide whether the caller should wait and try again.
39    /// `attempt` is 0-indexed (0 = the first retry decision after the
40    /// initial try has failed). Returns `Some(backoff)` to retry,
41    /// `None` to give up and propagate the error.
42    pub fn backoff_for(
43        &self,
44        attempt: usize,
45        status: Option<u16>,
46        is_network_error: bool,
47    ) -> Option<Duration> {
48        if attempt >= self.max_retries {
49            return None;
50        }
51
52        let is_transient = is_network_error || status.is_some_and(|s| (500..600).contains(&s));
53
54        if !is_transient {
55            return None;
56        }
57
58        let backoff = self.initial_backoff * 2u32.pow(attempt as u32);
59        Some(backoff.min(self.max_backoff))
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct OpenAiProvider {
65    base_url: String,
66    api_key: String,
67    model: String,
68    client: Client,
69    temperature: f64,
70    max_tokens: u32,
71    retry: RetryPolicy,
72    stream_tx: Option<StreamSender>,
73}
74
75impl OpenAiProvider {
76    pub fn new(
77        base_url: impl Into<String>,
78        api_key: impl Into<String>,
79        model: impl Into<String>,
80    ) -> Self {
81        Self {
82            base_url: base_url.into().trim_end_matches('/').to_string(),
83            api_key: api_key.into(),
84            model: model.into(),
85            client: Client::builder()
86                .timeout(Duration::from_secs(180))
87                .build()
88                .expect("reqwest client build"),
89            temperature: 0.2,
90            // DeepSeek defaults to a per-response cap of 4096 tokens; any
91            // tool call whose `arguments` string holds more than that — e.g.
92            // a `write_file` with a multi-kilobyte `contents` field — gets
93            // truncated server-side and arrives as malformed JSON. 16384 is
94            // both within DeepSeek's hard ceiling (8192 for v3, 32K-64K for
95            // newer models) and big enough for whole-file writes. Callers
96            // can override with `with_max_tokens` if their provider supports
97            // more or needs less.
98            max_tokens: 16384,
99            retry: RetryPolicy::default(),
100            stream_tx: None,
101        }
102    }
103
104    /// Enable streaming by providing a channel sender for partial tokens.
105    pub fn with_stream_tx(mut self, tx: StreamSender) -> Self {
106        self.stream_tx = Some(tx);
107        self
108    }
109
110    /// Build an `Error::Llm` with the model name prefixed.
111    fn make_err(&self, ctx: impl Into<String>) -> Error {
112        Error::Llm {
113            provider: self.model.clone(),
114            message: ctx.into(),
115        }
116    }
117
118    pub fn with_temperature(mut self, t: f64) -> Self {
119        self.temperature = t;
120        self
121    }
122
123    pub fn with_max_tokens(mut self, n: u32) -> Self {
124        self.max_tokens = n;
125        self
126    }
127
128    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
129        self.retry = policy;
130        self
131    }
132}
133
134#[async_trait]
135impl LlmProvider for OpenAiProvider {
136    #[tracing::instrument(skip(self, messages, tools), fields(
137        provider = %self.base_url.split('/').next_back().unwrap_or("unknown"),
138        model = %self.model
139    ))]
140    async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion> {
141        let body = build_request(
142            &self.model,
143            self.temperature,
144            self.max_tokens,
145            messages,
146            tools,
147        );
148        let url = format!("{}/chat/completions", self.base_url);
149
150        let mut attempt = 0;
151        loop {
152            tracing::debug!(target: "recursive::llm", request = %body, "POST {}", url);
153            let result = self
154                .client
155                .post(&url)
156                .bearer_auth(&self.api_key)
157                .json(&body)
158                .send()
159                .await;
160
161            match result {
162                Ok(resp) => {
163                    let status = resp.status();
164                    let is_network_error = false;
165
166                    if status.is_success() {
167                        let text = resp.text().await?;
168                        let parsed: ChatResponse = serde_json::from_str(&text).map_err(|e| {
169                            self.make_err(format!("failed to parse response: {e}; body: {text}"))
170                        })?;
171                        let choice = parsed
172                            .choices
173                            .into_iter()
174                            .next()
175                            .ok_or_else(|| self.make_err("response had no choices"))?;
176                        return Ok(parse_completion(choice, parsed.usage));
177                    }
178
179                    // Non-2xx response: check if it's transient (5xx)
180                    let text = resp.text().await?;
181                    tracing::debug!(target: "recursive::llm", body = %text, "error response");
182
183                    if let Some(backoff) =
184                        self.retry
185                            .backoff_for(attempt, Some(status.as_u16()), is_network_error)
186                    {
187                        tracing::warn!(
188                            target: "recursive::llm",
189                            attempt,
190                            backoff_ms = backoff.as_millis(),
191                            status = status.as_u16(),
192                            "transient HTTP error, retrying"
193                        );
194                        tokio::time::sleep(backoff).await;
195                        attempt += 1;
196                        continue;
197                    }
198
199                    // Non-transient (4xx or other)
200                    return Err(self.make_err(format!("HTTP {}: {}", status, text)));
201                }
202                Err(e) => {
203                    // Network error
204                    if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
205                        tracing::warn!(
206                            target: "recursive::llm",
207                            attempt,
208                            backoff_ms = backoff.as_millis(),
209                            error = %e,
210                            "network error, retrying"
211                        );
212                        tokio::time::sleep(backoff).await;
213                        attempt += 1;
214                        continue;
215                    }
216
217                    return Err(self.make_err(format!("request failed: {e}")));
218                }
219            }
220        }
221    }
222
223    async fn complete_structured(&self, req: StructuredRequest) -> Result<Value> {
224        let mut body = build_request(
225            &self.model,
226            self.temperature,
227            self.max_tokens,
228            &req.messages,
229            &[],
230        );
231        body["response_format"] = serde_json::json!({
232            "type": "json_schema",
233            "json_schema": {
234                "name": req.schema_name,
235                "strict": true,
236                "schema": req.schema,
237            }
238        });
239
240        let url = format!("{}/chat/completions", self.base_url);
241
242        let mut attempt = 0;
243        loop {
244            tracing::debug!(target: "recursive::llm", request = %body, "POST {} (structured)", url);
245            let result = self
246                .client
247                .post(&url)
248                .bearer_auth(&self.api_key)
249                .json(&body)
250                .send()
251                .await;
252
253            match result {
254                Ok(resp) => {
255                    let status = resp.status();
256                    let is_network_error = false;
257
258                    if status.is_success() {
259                        let text = resp.text().await?;
260                        let parsed: ChatResponse = serde_json::from_str(&text).map_err(|e| {
261                            self.make_err(format!("failed to parse response: {e}; body: {text}"))
262                        })?;
263                        let choice = parsed
264                            .choices
265                            .into_iter()
266                            .next()
267                            .ok_or_else(|| self.make_err("response had no choices"))?;
268                        let completion = parse_completion(choice, parsed.usage);
269                        // Parse the content as JSON
270                        if completion.content.trim().is_empty() {
271                            return Err(
272                                self.make_err("structured response had empty content".to_string())
273                            );
274                        }
275                        let parsed_json: Value = serde_json::from_str(&completion.content)
276                            .map_err(|e| {
277                                self.make_err(format!(
278                                    "failed to parse structured response as JSON: {e}; content: {}",
279                                    completion.content
280                                ))
281                            })?;
282                        return Ok(parsed_json);
283                    }
284
285                    let text = resp.text().await?;
286                    tracing::debug!(target: "recursive::llm", body = %text, "error response (structured)");
287
288                    if let Some(backoff) =
289                        self.retry
290                            .backoff_for(attempt, Some(status.as_u16()), is_network_error)
291                    {
292                        tracing::warn!(
293                            target: "recursive::llm",
294                            attempt,
295                            backoff_ms = backoff.as_millis(),
296                            status = status.as_u16(),
297                            "transient HTTP error, retrying (structured)"
298                        );
299                        tokio::time::sleep(backoff).await;
300                        attempt += 1;
301                        continue;
302                    }
303
304                    return Err(self.make_err(format!("HTTP {}: {}", status, text)));
305                }
306                Err(e) => {
307                    if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
308                        tracing::warn!(
309                            target: "recursive::llm",
310                            attempt,
311                            backoff_ms = backoff.as_millis(),
312                            error = %e,
313                            "network error, retrying (structured)"
314                        );
315                        tokio::time::sleep(backoff).await;
316                        attempt += 1;
317                        continue;
318                    }
319
320                    return Err(self.make_err(format!("request failed: {e}")));
321                }
322            }
323        }
324    }
325
326    async fn stream(
327        &self,
328        messages: &[Message],
329        tools: &[ToolSpec],
330        stream_tx: Option<StreamSender>,
331    ) -> Result<Completion> {
332        // If no stream_tx provided, fall back to the instance-level one
333        let tx = stream_tx.or_else(|| self.stream_tx.clone());
334        self.stream_inner(messages, tools, tx).await
335    }
336}
337
338impl OpenAiProvider {
339    async fn stream_inner(
340        &self,
341        messages: &[Message],
342        tools: &[ToolSpec],
343        stream_tx: Option<StreamSender>,
344    ) -> Result<Completion> {
345        let mut body = build_request(
346            &self.model,
347            self.temperature,
348            self.max_tokens,
349            messages,
350            tools,
351        );
352        body["stream"] = Value::Bool(true);
353
354        let url = format!("{}/chat/completions", self.base_url);
355
356        let mut attempt = 0;
357        loop {
358            tracing::debug!(target: "recursive::llm", request = %body, "POST {} (stream)", url);
359            let result = self
360                .client
361                .post(&url)
362                .bearer_auth(&self.api_key)
363                .json(&body)
364                .send()
365                .await;
366
367            match result {
368                Ok(resp) => {
369                    let status = resp.status();
370                    if status.is_success() {
371                        return self.parse_sse_stream(resp, stream_tx.clone()).await;
372                    }
373
374                    // Non-2xx: read body and check retry
375                    let text = resp.text().await?;
376                    tracing::debug!(target: "recursive::llm", body = %text, "error response (stream)");
377
378                    if let Some(backoff) =
379                        self.retry
380                            .backoff_for(attempt, Some(status.as_u16()), false)
381                    {
382                        tracing::warn!(
383                            target: "recursive::llm",
384                            attempt,
385                            backoff_ms = backoff.as_millis(),
386                            status = status.as_u16(),
387                            "transient HTTP error, retrying (stream)"
388                        );
389                        tokio::time::sleep(backoff).await;
390                        attempt += 1;
391                        continue;
392                    }
393
394                    return Err(self.make_err(format!("HTTP {}: {}", status, text)));
395                }
396                Err(e) => {
397                    if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
398                        tracing::warn!(
399                            target: "recursive::llm",
400                            attempt,
401                            backoff_ms = backoff.as_millis(),
402                            error = %e,
403                            "network error, retrying (stream)"
404                        );
405                        tokio::time::sleep(backoff).await;
406                        attempt += 1;
407                        continue;
408                    }
409
410                    return Err(self.make_err(format!("request failed: {e}")));
411                }
412            }
413        }
414    }
415
416    /// Parse an SSE stream from a successful HTTP response.
417    ///
418    /// Reads `data: {...}\n\n` chunks line-by-line, extracts
419    /// `choices[0].delta.content` deltas, accumulates them, and emits
420    /// each delta through `stream_tx` if configured. Returns the final
421    /// `Completion` matching the non-streaming shape.
422    async fn parse_sse_stream(
423        &self,
424        resp: reqwest::Response,
425        stream_tx: Option<StreamSender>,
426    ) -> Result<Completion> {
427        let mut content = String::new();
428        let mut reasoning_content = String::new();
429        let tool_calls: Vec<ToolCall> = Vec::new();
430        let mut finish_reason: Option<String> = None;
431        let mut usage: Option<TokenUsage> = None;
432
433        // Read the byte stream line by line
434        let reader = resp.text().await?;
435        for line in reader.lines() {
436            if let Some(data) = line.strip_prefix("data: ") {
437                // Skip the final "[DONE]" marker
438                if data.trim() == "[DONE]" {
439                    break;
440                }
441
442                // Parse the JSON chunk
443                let chunk: Value = serde_json::from_str(data)
444                    .map_err(|e| self.make_err(format!("SSE parse error: {e}; data: {data}")))?;
445
446                // Extract delta content
447                if let Some(choices) = chunk.get("choices").and_then(|c| c.as_array()) {
448                    if let Some(choice) = choices.first() {
449                        // Delta content
450                        if let Some(delta) = choice.get("delta") {
451                            if let Some(delta_content) =
452                                delta.get("content").and_then(|c| c.as_str())
453                            {
454                                if !delta_content.is_empty() {
455                                    content.push_str(delta_content);
456                                    if let Some(ref tx) = stream_tx {
457                                        let _ = tx.send(delta_content.to_string());
458                                    }
459                                }
460                            }
461                            // Accumulate reasoning_content deltas (DeepSeek thinking mode)
462                            if let Some(delta_reasoning) =
463                                delta.get("reasoning_content").and_then(|c| c.as_str())
464                            {
465                                if !delta_reasoning.is_empty() {
466                                    reasoning_content.push_str(delta_reasoning);
467                                }
468                            }
469                        }
470
471                        // Finish reason (only on the last chunk)
472                        if let Some(fr) = choice.get("finish_reason").and_then(|f| f.as_str()) {
473                            if !fr.is_empty() {
474                                finish_reason = Some(fr.to_string());
475                            }
476                        }
477                    }
478                }
479
480                // Usage (only on the last chunk for some providers)
481                if let Some(u) = chunk.get("usage") {
482                    let prompt =
483                        u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
484                    let completion = u
485                        .get("completion_tokens")
486                        .and_then(|v| v.as_u64())
487                        .unwrap_or(0) as u32;
488                    let total = u.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
489                    let cache_hit = u
490                        .get("prompt_cache_hit_tokens")
491                        .and_then(|v| v.as_u64())
492                        .unwrap_or(0) as u32;
493                    let cache_miss = u
494                        .get("prompt_cache_miss_tokens")
495                        .and_then(|v| v.as_u64())
496                        .unwrap_or(0) as u32;
497                    usage = Some(TokenUsage {
498                        prompt_tokens: prompt,
499                        completion_tokens: completion,
500                        total_tokens: total,
501                        cache_hit_tokens: cache_hit,
502                        cache_miss_tokens: cache_miss,
503                    });
504                }
505            }
506        }
507
508        Ok(Completion {
509            content,
510            tool_calls,
511            finish_reason,
512            usage,
513            reasoning_content: if reasoning_content.is_empty() {
514                None
515            } else {
516                Some(reasoning_content)
517            },
518        })
519    }
520}
521
522fn build_request(
523    model: &str,
524    temperature: f64,
525    max_tokens: u32,
526    messages: &[Message],
527    tools: &[ToolSpec],
528) -> Value {
529    let mut req = serde_json::json!({
530        "model": model,
531        "temperature": temperature,
532        "max_tokens": max_tokens,
533        "messages": messages.iter().map(serialize_message).collect::<Vec<_>>(),
534    });
535    if !tools.is_empty() {
536        let tools_json: Vec<Value> = tools
537            .iter()
538            .map(|t| {
539                serde_json::json!({
540                    "type": "function",
541                    "function": {
542                        "name": t.name,
543                        "description": t.description,
544                        "parameters": t.parameters,
545                    }
546                })
547            })
548            .collect();
549        req["tools"] = Value::Array(tools_json);
550        req["tool_choice"] = Value::String("auto".into());
551    }
552    req
553}
554
555fn serialize_message(m: &Message) -> Value {
556    let role = match m.role {
557        Role::System => "system",
558        Role::User => "user",
559        Role::Assistant => "assistant",
560        Role::Tool => "tool",
561    };
562    let mut obj = serde_json::Map::new();
563    obj.insert("role".into(), Value::String(role.into()));
564    obj.insert("content".into(), Value::String(m.content.clone()));
565    if let Some(id) = &m.tool_call_id {
566        obj.insert("tool_call_id".into(), Value::String(id.clone()));
567    }
568    // Echo reasoning_content back to the API (required by DeepSeek thinking mode)
569    if let Some(ref reasoning) = m.reasoning_content {
570        obj.insert(
571            "reasoning_content".into(),
572            Value::String(reasoning.clone()),
573        );
574    }
575    if !m.tool_calls.is_empty() {
576        let calls: Vec<Value> = m
577            .tool_calls
578            .iter()
579            .map(|c| {
580                serde_json::json!({
581                    "id": c.id,
582                    "type": "function",
583                    "function": {
584                        "name": c.name,
585                        "arguments": serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into()),
586                    }
587                })
588            })
589            .collect();
590        obj.insert("tool_calls".into(), Value::Array(calls));
591    }
592    Value::Object(obj)
593}
594
595#[derive(Debug, Deserialize)]
596struct ChatResponse {
597    choices: Vec<ChatChoice>,
598    #[serde(default)]
599    usage: Option<ResponseUsage>,
600}
601
602#[derive(Debug, Deserialize)]
603struct ResponseUsage {
604    #[serde(default)]
605    prompt_tokens: Option<u32>,
606    #[serde(default)]
607    completion_tokens: Option<u32>,
608    #[serde(default)]
609    total_tokens: Option<u32>,
610    #[serde(default)]
611    prompt_cache_hit_tokens: Option<u32>,
612    #[serde(default)]
613    prompt_cache_miss_tokens: Option<u32>,
614}
615
616impl ResponseUsage {
617    fn to_token_usage(&self) -> TokenUsage {
618        TokenUsage {
619            prompt_tokens: self.prompt_tokens.unwrap_or(0),
620            completion_tokens: self.completion_tokens.unwrap_or(0),
621            total_tokens: self.total_tokens.unwrap_or(0),
622            cache_hit_tokens: self.prompt_cache_hit_tokens.unwrap_or(0),
623            cache_miss_tokens: self.prompt_cache_miss_tokens.unwrap_or(0),
624        }
625    }
626}
627
628#[derive(Debug, Deserialize)]
629struct ChatChoice {
630    message: ChatChoiceMessage,
631    #[serde(default)]
632    finish_reason: Option<String>,
633}
634
635#[derive(Debug, Deserialize)]
636struct ChatChoiceMessage {
637    #[serde(default)]
638    content: Option<String>,
639    #[serde(default)]
640    reasoning_content: Option<String>,
641    #[serde(default)]
642    tool_calls: Vec<RawToolCall>,
643}
644
645#[derive(Debug, Deserialize, Serialize)]
646struct RawToolCall {
647    id: String,
648    #[serde(default)]
649    function: RawFunction,
650}
651
652#[derive(Debug, Default, Deserialize, Serialize)]
653struct RawFunction {
654    #[serde(default)]
655    name: String,
656    #[serde(default)]
657    arguments: String,
658}
659
660fn parse_completion(choice: ChatChoice, usage: Option<ResponseUsage>) -> Completion {
661    let content = choice.message.content.unwrap_or_default();
662    let reasoning_content = choice.message.reasoning_content;
663    let tool_calls = choice
664        .message
665        .tool_calls
666        .into_iter()
667        .map(|c| {
668            let args: Value = if c.function.arguments.trim().is_empty() {
669                Value::Object(Default::default())
670            } else {
671                serde_json::from_str(&c.function.arguments)
672                    .unwrap_or_else(|_| Value::String(c.function.arguments.clone()))
673            };
674            ToolCall {
675                id: c.id,
676                name: c.function.name,
677                arguments: args,
678            }
679        })
680        .collect();
681    Completion {
682        content,
683        tool_calls,
684        finish_reason: choice.finish_reason,
685        usage: usage.map(|u| u.to_token_usage()),
686        reasoning_content,
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn parses_plain_text_choice() {
696        let raw = r#"{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}"#;
697        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
698        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
699        assert_eq!(c.content, "hi");
700        assert!(c.tool_calls.is_empty());
701        assert_eq!(c.finish_reason.as_deref(), Some("stop"));
702        assert!(c.usage.is_none());
703    }
704
705    #[test]
706    fn parses_tool_call_choice() {
707        let raw = r#"{
708            "choices":[{
709                "message":{
710                    "role":"assistant",
711                    "content":null,
712                    "tool_calls":[{
713                        "id":"call_1",
714                        "type":"function",
715                        "function":{"name":"read_file","arguments":"{\"path\":\"src/lib.rs\"}"}
716                    }]
717                },
718                "finish_reason":"tool_calls"
719            }]
720        }"#;
721        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
722        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
723        assert_eq!(c.tool_calls.len(), 1);
724        assert_eq!(c.tool_calls[0].name, "read_file");
725        assert_eq!(c.tool_calls[0].arguments["path"], "src/lib.rs");
726    }
727
728    #[test]
729    fn parses_usage_from_response() {
730        let raw = r#"{
731            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
732            "usage":{"prompt_tokens":41,"completion_tokens":7,"total_tokens":48}
733        }"#;
734        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
735        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
736        assert!(c.usage.is_some());
737        let u = c.usage.unwrap();
738        assert_eq!(u.prompt_tokens, 41);
739        assert_eq!(u.completion_tokens, 7);
740        assert_eq!(u.total_tokens, 48);
741    }
742
743    #[test]
744    fn parses_missing_usage_as_none() {
745        let raw = r#"{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}"#;
746        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
747        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
748        assert!(c.usage.is_none());
749    }
750
751    #[test]
752    fn parses_partial_usage_fills_zeros() {
753        let raw = r#"{
754            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
755            "usage":{"total_tokens":50}
756        }"#;
757        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
758        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
759        assert!(c.usage.is_some());
760        let u = c.usage.unwrap();
761        assert_eq!(u.prompt_tokens, 0);
762        assert_eq!(u.completion_tokens, 0);
763        assert_eq!(u.total_tokens, 50);
764        assert_eq!(u.cache_hit_tokens, 0);
765        assert_eq!(u.cache_miss_tokens, 0);
766    }
767
768    #[test]
769    fn parses_cache_fields_from_deepseek_usage() {
770        let raw = r#"{
771            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
772            "usage":{
773                "prompt_tokens":100,
774                "completion_tokens":50,
775                "total_tokens":150,
776                "prompt_cache_hit_tokens":60,
777                "prompt_cache_miss_tokens":40
778            }
779        }"#;
780        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
781        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
782        assert!(c.usage.is_some());
783        let u = c.usage.unwrap();
784        assert_eq!(u.prompt_tokens, 100);
785        assert_eq!(u.completion_tokens, 50);
786        assert_eq!(u.total_tokens, 150);
787        assert_eq!(u.cache_hit_tokens, 60);
788        assert_eq!(u.cache_miss_tokens, 40);
789    }
790
791    #[test]
792    fn parses_cache_fields_as_zero_when_absent() {
793        let raw = r#"{
794            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
795            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}
796        }"#;
797        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
798        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
799        assert!(c.usage.is_some());
800        let u = c.usage.unwrap();
801        assert_eq!(u.cache_hit_tokens, 0);
802        assert_eq!(u.cache_miss_tokens, 0);
803    }
804
805    #[test]
806    fn serialises_assistant_with_tool_calls() {
807        let msg = Message::assistant_with_tool_calls(
808            "",
809            vec![ToolCall {
810                id: "abc".into(),
811                name: "write_file".into(),
812                arguments: serde_json::json!({"path":"a","contents":"b"}),
813            }],
814        );
815        let v = serialize_message(&msg);
816        assert_eq!(v["tool_calls"][0]["function"]["name"], "write_file");
817        let args = v["tool_calls"][0]["function"]["arguments"]
818            .as_str()
819            .unwrap();
820        let decoded: Value = serde_json::from_str(args).unwrap();
821        assert_eq!(decoded["contents"], "b");
822    }
823
824    #[test]
825    fn builds_request_without_tools_omits_field() {
826        let req = build_request("m", 0.2, 16384, &[Message::user("hi")], &[]);
827        assert!(req.get("tools").is_none());
828        assert_eq!(req["messages"][0]["role"], "user");
829        assert_eq!(req["max_tokens"], 16384);
830    }
831
832    #[test]
833    fn builds_request_includes_max_tokens() {
834        let req = build_request("m", 0.2, 1024, &[Message::user("hi")], &[]);
835        assert_eq!(req["max_tokens"], 1024);
836    }
837
838    #[tokio::test]
839    async fn error_includes_model_name_on_network_failure() {
840        // Bind a listener on an ephemeral port, then drop it so the port is freed.
841        // The next connect attempt gets ECONNREFUSED (a network error).
842        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
843        let addr = listener.local_addr().unwrap();
844        drop(listener);
845
846        // Small sleep so the OS releases the port.
847        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
848
849        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-model");
850        let err = provider
851            .complete(&[Message::user("hi")], &[])
852            .await
853            .unwrap_err();
854        let msg = err.to_string();
855        assert!(
856            msg.contains("test-model"),
857            "error should contain model name: {msg}"
858        );
859    }
860
861    #[tokio::test]
862    async fn error_includes_model_name_and_status_on_http_error() {
863        // Spawn a one-shot TCP listener that sends a 400 response.
864        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
865        let addr = listener.local_addr().unwrap();
866
867        // Use a dedicated thread with a blocking server to avoid tokio issues.
868        std::thread::spawn(move || {
869            let (mut stream, _) = listener.accept().unwrap();
870            use std::io::{Read, Write};
871            let mut buf = [0u8; 4096];
872            let _ = stream.read(&mut buf);
873            write!(
874                stream,
875                "HTTP/1.1 400 Bad Request\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
876            )
877            .unwrap();
878            stream.flush().unwrap();
879        });
880
881        // Give the server a moment to start
882        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
883
884        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-model-http");
885        let err = provider
886            .complete(&[Message::user("hi")], &[])
887            .await
888            .unwrap_err();
889        let msg = err.to_string();
890        assert!(
891            msg.contains("test-model-http"),
892            "error should contain model name: {msg}"
893        );
894        assert!(
895            msg.contains("400"),
896            "error should contain HTTP status: {msg}"
897        );
898    }
899
900    #[tokio::test]
901    async fn stream_concatenates_sse_chunks() {
902        // Spawn a one-shot TCP server that serves a canned SSE response
903        // with 3 chunks. Assert the returned Completion's content is the
904        // concatenation of the deltas.
905        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
906        let addr = listener.local_addr().unwrap();
907
908        std::thread::spawn(move || {
909            let (mut stream, _) = listener.accept().unwrap();
910            use std::io::{Read, Write};
911            let mut buf = [0u8; 4096];
912            let _ = stream.read(&mut buf);
913            let body = "\
914data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\
915data: {\"choices\":[{\"delta\":{\"content\":\" \"},\"finish_reason\":null}]}\n\n\
916data: {\"choices\":[{\"delta\":{\"content\":\"World\"},\"finish_reason\":\"stop\"}]}\n\n\
917data: [DONE]\n\n";
918            write!(
919                stream,
920                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
921                body.len(),
922                body,
923            )
924            .unwrap();
925            stream.flush().unwrap();
926        });
927
928        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
929
930        let provider =
931            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-stream-model");
932        let completion = provider
933            .stream(&[Message::user("hi")], &[], None)
934            .await
935            .unwrap();
936        assert_eq!(completion.content, "Hello World");
937        assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
938    }
939
940    #[tokio::test]
941    async fn stream_fallback_delegates_to_complete() {
942        // MockProvider doesn't override stream, so it falls back to complete.
943        // Verify the fallback path works by using a MockProvider.
944        use crate::llm::MockProvider;
945        let provider = MockProvider::new(vec![super::Completion {
946            content: "fallback works".to_string(),
947            tool_calls: vec![],
948            finish_reason: Some("stop".to_string()),
949            usage: None,
950            reasoning_content: None,
951        }]);
952        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
953        let completion = provider
954            .stream(&[Message::user("hi")], &[], Some(tx))
955            .await
956            .unwrap();
957        assert_eq!(completion.content, "fallback works");
958        // Should have received the full content as a single delta
959        let delta = rx.recv().await.unwrap();
960        assert_eq!(delta, "fallback works");
961    }
962
963    #[test]
964    fn policy_retries_5xx_with_exponential_backoff() {
965        let policy = RetryPolicy::default(); // max_retries = 2
966                                             // Attempt 0: should return initial_backoff (1s)
967        assert_eq!(
968            policy.backoff_for(0, Some(503), false),
969            Some(Duration::from_secs(1))
970        );
971        // Attempt 1: should return 2s (1s * 2^1)
972        assert_eq!(
973            policy.backoff_for(1, Some(500), false),
974            Some(Duration::from_secs(2))
975        );
976        // Attempt 2: should return None (exceeds max_retries=2)
977        assert_eq!(policy.backoff_for(2, Some(500), false), None);
978    }
979
980    #[test]
981    fn policy_retries_network_errors() {
982        let policy = RetryPolicy::default();
983        // Network error at attempt 0 should return initial_backoff
984        assert_eq!(
985            policy.backoff_for(0, None, true),
986            Some(Duration::from_secs(1))
987        );
988    }
989
990    #[test]
991    fn policy_does_not_retry_4xx() {
992        let policy = RetryPolicy::default();
993        // 4xx errors should not be retried
994        assert_eq!(policy.backoff_for(0, Some(400), false), None);
995        assert_eq!(policy.backoff_for(0, Some(401), false), None);
996        assert_eq!(policy.backoff_for(0, Some(404), false), None);
997        assert_eq!(policy.backoff_for(0, Some(429), false), None);
998    }
999
1000    #[test]
1001    fn policy_caps_backoff_at_max() {
1002        let policy = RetryPolicy {
1003            max_retries: 10,
1004            initial_backoff: Duration::from_secs(1),
1005            max_backoff: Duration::from_secs(3),
1006        };
1007        // At attempt 5, exponential backoff would be 32s but capped to 3s
1008        assert_eq!(
1009            policy.backoff_for(5, Some(500), false),
1010            Some(Duration::from_secs(3))
1011        );
1012    }
1013
1014    #[tokio::test]
1015    async fn openai_structured_includes_schema_in_request_body() {
1016        // Spawn a mock server that captures the request body and returns
1017        // a valid JSON response. Assert the request body contains the
1018        // response_format block with the schema.
1019        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1020        let addr = listener.local_addr().unwrap();
1021        let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
1022        let captured_clone = captured.clone();
1023
1024        std::thread::spawn(move || {
1025            let (mut stream, _) = listener.accept().unwrap();
1026            use std::io::{Read, Write};
1027            let mut buf = [0u8; 8192];
1028            let n = stream.read(&mut buf).unwrap();
1029            let request = String::from_utf8_lossy(&buf[..n]).to_string();
1030            // Extract the JSON body (after the blank line)
1031            if let Some(body_start) = request.find("\r\n\r\n") {
1032                let body = request[body_start + 4..].trim().to_string();
1033                *captured_clone.lock().unwrap() = body.clone();
1034            }
1035            let response_body = r#"{"choices":[{"message":{"role":"assistant","content":"{\"answer\":42}"},"finish_reason":"stop"}],"usage":null}"#;
1036            write!(
1037                stream,
1038                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1039                response_body.len(),
1040                response_body,
1041            )
1042            .unwrap();
1043            stream.flush().unwrap();
1044        });
1045
1046        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1047
1048        let provider =
1049            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-structured-model");
1050
1051        let schema = serde_json::json!({
1052            "type": "object",
1053            "properties": {
1054                "answer": {"type": "integer"}
1055            },
1056            "required": ["answer"]
1057        });
1058
1059        let req = StructuredRequest {
1060            messages: vec![Message::user("what is 6 times 7?".to_string())],
1061            schema: schema.clone(),
1062            schema_name: "math_answer".to_string(),
1063        };
1064
1065        let _ = provider.complete_structured(req).await;
1066
1067        // Check the captured request body
1068        let body_str = captured.lock().unwrap().clone();
1069        let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
1070
1071        // Assert response_format is present with the right shape
1072        let rf = body.get("response_format").unwrap();
1073        assert_eq!(rf["type"], "json_schema");
1074        assert_eq!(rf["json_schema"]["name"], "math_answer");
1075        assert_eq!(rf["json_schema"]["strict"], true);
1076        assert_eq!(rf["json_schema"]["schema"], schema);
1077    }
1078
1079    #[tokio::test]
1080    async fn openai_structured_parses_response_json() {
1081        // Spawn a mock server that returns a known JSON response.
1082        // Assert the parsed value matches.
1083        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1084        let addr = listener.local_addr().unwrap();
1085
1086        std::thread::spawn(move || {
1087            let (mut stream, _) = listener.accept().unwrap();
1088            use std::io::{Read, Write};
1089            let mut buf = [0u8; 8192];
1090            let _ = stream.read(&mut buf);
1091            // Return a JSON object with summary and kept_facts
1092            let response_body = r#"{"choices":[{"message":{"role":"assistant","content":"{\"summary\":\"test\",\"kept_facts\":[\"a\",\"b\"]}"},"finish_reason":"stop"}],"usage":null}"#;
1093            write!(
1094                stream,
1095                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1096                response_body.len(),
1097                response_body,
1098            )
1099            .unwrap();
1100            stream.flush().unwrap();
1101        });
1102
1103        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1104
1105        let provider =
1106            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-structured-model");
1107
1108        let schema = serde_json::json!({
1109            "type": "object",
1110            "properties": {
1111                "summary": {"type": "string"},
1112                "kept_facts": {"type": "array", "items": {"type": "string"}}
1113            },
1114            "required": ["summary", "kept_facts"]
1115        });
1116
1117        let req = StructuredRequest {
1118            messages: vec![Message::user("summarize".to_string())],
1119            schema,
1120            schema_name: "summary".to_string(),
1121        };
1122
1123        let result = provider.complete_structured(req).await;
1124        assert!(result.is_ok(), "got error: {:?}", result.err());
1125        let value = result.unwrap();
1126        assert_eq!(value["summary"], "test");
1127        assert_eq!(value["kept_facts"][0], "a");
1128        assert_eq!(value["kept_facts"][1], "b");
1129    }
1130}