1use 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#[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 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 max_tokens: 16384,
99 retry: RetryPolicy::default(),
100 stream_tx: None,
101 }
102 }
103
104 pub fn with_stream_tx(mut self, tx: StreamSender) -> Self {
106 self.stream_tx = Some(tx);
107 self
108 }
109
110 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 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 return Err(self.make_err(format!("HTTP {}: {}", status, text)));
201 }
202 Err(e) => {
203 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 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 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 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 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 let reader = resp.text().await?;
435 for line in reader.lines() {
436 if let Some(data) = line.strip_prefix("data: ") {
437 if data.trim() == "[DONE]" {
439 break;
440 }
441
442 let chunk: Value = serde_json::from_str(data)
444 .map_err(|e| self.make_err(format!("SSE parse error: {e}; data: {data}")))?;
445
446 if let Some(choices) = chunk.get("choices").and_then(|c| c.as_array()) {
448 if let Some(choice) = choices.first() {
449 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 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 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 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 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 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
843 let addr = listener.local_addr().unwrap();
844 drop(listener);
845
846 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 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
865 let addr = listener.local_addr().unwrap();
866
867 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 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 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 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 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(); assert_eq!(
968 policy.backoff_for(0, Some(503), false),
969 Some(Duration::from_secs(1))
970 );
971 assert_eq!(
973 policy.backoff_for(1, Some(500), false),
974 Some(Duration::from_secs(2))
975 );
976 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 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 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 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 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 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 let body_str = captured.lock().unwrap().clone();
1069 let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
1070
1071 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 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 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}