1use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use futures_util::StreamExt;
27use reqwest::Client;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use super::search::{KeywordSearchEngine, SpecWithHint, ToolSearchEngine};
32use super::StructuredRequest;
33use super::{
34 ChatProvider, Completion, RetryPolicy, StreamChunk, StreamSender, TokenUsage, ToolCall,
35 ToolSpec,
36};
37use crate::error::{Error, Result};
38use crate::message::{Message, Role};
39
40const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearchTool";
41
42#[derive(Debug, Clone)]
43pub struct OpenAiProvider {
44 base_url: String,
45 api_key: String,
46 model: String,
47 client: Client,
48 temperature: f64,
49 max_tokens: u32,
50 retry: RetryPolicy,
51 stream_tx: Option<StreamSender>,
52 search_engine: Arc<dyn ToolSearchEngine>,
55 max_search_rounds: usize,
58}
59
60impl OpenAiProvider {
61 pub fn new(
62 base_url: impl Into<String>,
63 api_key: impl Into<String>,
64 model: impl Into<String>,
65 ) -> crate::error::Result<Self> {
66 let client = Client::builder()
67 .timeout(Duration::from_secs(180))
68 .build()
69 .map_err(|e| crate::error::Error::Config {
70 message: format!("failed to build HTTP client: {e}"),
71 })?;
72 Ok(Self {
73 base_url: base_url.into().trim_end_matches('/').to_string(),
74 api_key: api_key.into(),
75 model: model.into(),
76 client,
77 temperature: 0.2,
78 max_tokens: crate::llm::DEFAULT_MAX_TOKENS,
88 retry: RetryPolicy::default(),
89 stream_tx: None,
90 search_engine: Arc::new(KeywordSearchEngine::new()),
91 max_search_rounds: 3,
92 })
93 }
94
95 pub fn with_search_engine(mut self, engine: Arc<dyn ToolSearchEngine>) -> Self {
97 self.search_engine = engine;
98 self
99 }
100
101 pub fn with_max_search_rounds(mut self, n: usize) -> Self {
104 self.max_search_rounds = n;
105 self
106 }
107
108 pub fn with_stream_tx(mut self, tx: StreamSender) -> Self {
110 self.stream_tx = Some(tx);
111 self
112 }
113
114 fn make_err(&self, ctx: impl Into<String>) -> Error {
116 Error::Llm {
117 provider: self.model.clone(),
118 message: ctx.into(),
119 }
120 }
121
122 pub fn with_temperature(mut self, t: f64) -> Self {
123 self.temperature = t;
124 self
125 }
126
127 pub fn with_max_tokens(mut self, n: u32) -> Self {
128 self.max_tokens = n;
129 self
130 }
131
132 pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
133 self.retry = policy;
134 self
135 }
136
137 async fn post_json_with_retry(&self, url: &str, body: &Value, label: &str) -> Result<String> {
143 let mut attempt = 0;
144 loop {
145 tracing::debug!(target: "recursive::llm", request = %body, "POST {url} ({label})");
146 let result = self
147 .client
148 .post(url)
149 .bearer_auth(&self.api_key)
150 .json(body)
151 .send()
152 .await;
153
154 match result {
155 Ok(resp) => {
156 let status = resp.status();
157 if status.is_success() {
158 let text = resp.text().await?;
159 if text.trim().is_empty() {
160 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
161 tracing::warn!(
162 target: "recursive::llm",
163 attempt,
164 backoff_ms = backoff.as_millis(),
165 "HTTP 200 but empty body ({label}), retrying"
166 );
167 tokio::time::sleep(backoff).await;
168 attempt += 1;
169 continue;
170 }
171 return Err(self.make_err("HTTP 200 but response body is empty"));
172 }
173 return Ok(text);
174 }
175
176 let text = resp.text().await?;
177 tracing::debug!(target: "recursive::llm", body = %text, "error response ({label})");
178
179 if let Some(backoff) =
180 self.retry
181 .backoff_for(attempt, Some(status.as_u16()), false)
182 {
183 tracing::warn!(
184 target: "recursive::llm",
185 attempt,
186 backoff_ms = backoff.as_millis(),
187 status = status.as_u16(),
188 "transient HTTP error, retrying ({label})"
189 );
190 tokio::time::sleep(backoff).await;
191 attempt += 1;
192 continue;
193 }
194
195 return Err(self.make_err(format!("HTTP {status}: {text}")));
196 }
197 Err(e) => {
198 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
199 tracing::warn!(
200 target: "recursive::llm",
201 attempt,
202 backoff_ms = backoff.as_millis(),
203 error = %e,
204 "network error, retrying ({label})"
205 );
206 tokio::time::sleep(backoff).await;
207 attempt += 1;
208 continue;
209 }
210 return Err(self.make_err(format!("request failed: {e}")));
211 }
212 }
213 }
214 }
215}
216
217#[async_trait]
218impl ChatProvider for OpenAiProvider {
219 #[tracing::instrument(skip(self, messages, tools), fields(
220 provider = %self.base_url.split('/').next_back().unwrap_or("unknown"),
221 model = %self.model
222 ))]
223 async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion> {
224 let body = build_request(
225 &self.model,
226 self.temperature,
227 self.max_tokens,
228 messages,
229 tools,
230 );
231 let url = format!("{}/chat/completions", self.base_url);
232 let text = self.post_json_with_retry(&url, &body, "complete").await?;
233 let parsed: ChatResponse = serde_json::from_str(&text)
234 .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
235 let choice = parsed
236 .choices
237 .into_iter()
238 .next()
239 .ok_or_else(|| self.make_err("response had no choices"))?;
240 Ok(parse_completion(choice, parsed.usage))
241 }
242
243 async fn complete_structured(&self, req: StructuredRequest) -> Result<Value> {
244 let mut body = build_request(
245 &self.model,
246 self.temperature,
247 self.max_tokens,
248 &req.messages,
249 &[],
250 );
251 body["response_format"] = serde_json::json!({
252 "type": "json_schema",
253 "json_schema": {
254 "name": req.schema_name,
255 "strict": true,
256 "schema": req.schema,
257 }
258 });
259 let url = format!("{}/chat/completions", self.base_url);
260 let text = self.post_json_with_retry(&url, &body, "structured").await?;
261 let parsed: ChatResponse = serde_json::from_str(&text)
262 .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
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() {
270 return Err(self.make_err("structured response had empty content"));
271 }
272 serde_json::from_str(&completion.content).map_err(|e| {
273 self.make_err(format!(
274 "failed to parse structured response as JSON: {e}; content: {}",
275 completion.content
276 ))
277 })
278 }
279
280 async fn complete_with_search(
281 &self,
282 messages: &[Message],
283 eager_tools: &[SpecWithHint],
284 deferred_tools: &[SpecWithHint],
285 ) -> Result<Completion> {
286 if deferred_tools.is_empty() {
287 let specs: Vec<ToolSpec> = eager_tools.iter().map(|(s, _)| s.clone()).collect();
288 return self.complete(messages, &specs).await;
289 }
290 self.run_search_loop(messages, eager_tools, deferred_tools, &[], 0)
291 .await
292 }
293
294 async fn stream_with_search(
295 &self,
296 messages: &[Message],
297 eager_tools: &[SpecWithHint],
298 deferred_tools: &[SpecWithHint],
299 stream_tx: Option<StreamSender>,
300 cancel_token: Option<tokio_util::sync::CancellationToken>,
301 ) -> Result<Completion> {
302 if deferred_tools.is_empty() {
303 let specs: Vec<ToolSpec> = eager_tools.iter().map(|(s, _)| s.clone()).collect();
304 let tx = stream_tx.or_else(|| self.stream_tx.clone());
305 return self.stream_inner(messages, &specs, tx, cancel_token).await;
306 }
307 self.run_stream_search_loop(
309 messages,
310 eager_tools,
311 deferred_tools,
312 &[],
313 0,
314 stream_tx,
315 cancel_token,
316 )
317 .await
318 }
319
320 async fn stream(
321 &self,
322 messages: &[Message],
323 tools: &[ToolSpec],
324 stream_tx: Option<StreamSender>,
325 cancel_token: Option<tokio_util::sync::CancellationToken>,
326 ) -> Result<Completion> {
327 let tx = stream_tx.or_else(|| self.stream_tx.clone());
329 self.stream_inner(messages, tools, tx, cancel_token).await
330 }
331}
332
333impl OpenAiProvider {
334 fn tool_search_spec() -> SpecWithHint {
335 let spec = ToolSpec {
336 name: TOOL_SEARCH_TOOL_NAME.to_string(),
337 description: "Fetches full schema definitions for deferred tools so they can be \
338 called. Until fetched, only the name is known — there is no parameter schema, so \
339 the tool cannot be invoked. Use `select:<tool_name>` for direct selection, or \
340 keywords to search."
341 .to_string(),
342 parameters: serde_json::json!({
343 "type": "object",
344 "properties": {
345 "query": {
346 "type": "string",
347 "description": "Query to find deferred tools."
348 },
349 "max_results": {
350 "type": "integer",
351 "description": "Maximum number of results to return (default: 5)",
352 "default": 5
353 }
354 },
355 "required": ["query"]
356 }),
357 };
358 (spec, Some("find search discover tools lookup".to_string()))
359 }
360
361 async fn run_search_loop(
368 &self,
369 messages: &[Message],
370 eager_tools: &[SpecWithHint],
371 deferred_tools: &[SpecWithHint],
372 loaded_tools: &[ToolSpec],
373 round: usize,
374 ) -> Result<Completion> {
375 let mut call_tools: Vec<ToolSpec> = Vec::new();
377 call_tools.push(Self::tool_search_spec().0);
378 call_tools.extend(eager_tools.iter().map(|(s, _)| s.clone()));
379 call_tools.extend_from_slice(loaded_tools);
380
381 let completion = self.complete(messages, &call_tools).await?;
382
383 let search_call = completion
384 .tool_calls
385 .iter()
386 .find(|c| c.name == TOOL_SEARCH_TOOL_NAME);
387
388 let search_call = match search_call {
389 None => return Ok(completion),
390 Some(c) => c.clone(),
391 };
392
393 if round >= self.max_search_rounds {
394 tracing::warn!(
395 target: "recursive::llm",
396 round,
397 max = self.max_search_rounds,
398 "ToolSearchTool (openai): hit max_search_rounds, returning current completion"
399 );
400 return Ok(completion);
401 }
402
403 let query = search_call
404 .arguments
405 .get("query")
406 .and_then(|v| v.as_str())
407 .unwrap_or("");
408 let names = self.search_engine.resolve(query, deferred_tools);
409 let matched: Vec<ToolSpec> = deferred_tools
410 .iter()
411 .filter(|(s, _)| names.contains(&s.name))
412 .map(|(s, _)| s.clone())
413 .collect();
414 let result_json = serde_json::to_string(&matched).unwrap_or_else(|_| "[]".into());
415
416 let mut next_messages: Vec<Message> = messages.to_vec();
418 next_messages.push(Message {
419 role: Role::Assistant,
420 content: completion.content.clone(),
421 tool_calls: completion.tool_calls.clone(),
422 tool_call_id: None,
423 reasoning_content: completion.reasoning_content.clone(),
424 is_compaction_summary: false,
425 });
426 next_messages.push(Message {
427 role: Role::Tool,
428 content: result_json,
429 tool_calls: vec![],
430 tool_call_id: Some(search_call.id.clone()),
431 reasoning_content: None,
432 is_compaction_summary: false,
433 });
434
435 let mut next_loaded = loaded_tools.to_vec();
436 next_loaded.extend_from_slice(&matched);
437
438 Box::pin(self.run_search_loop(
439 &next_messages,
440 eager_tools,
441 deferred_tools,
442 &next_loaded,
443 round + 1,
444 ))
445 .await
446 }
447
448 #[allow(clippy::too_many_arguments)]
450 async fn run_stream_search_loop(
451 &self,
452 messages: &[Message],
453 eager_tools: &[SpecWithHint],
454 deferred_tools: &[SpecWithHint],
455 loaded_tools: &[ToolSpec],
456 round: usize,
457 stream_tx: Option<StreamSender>,
458 cancel_token: Option<tokio_util::sync::CancellationToken>,
459 ) -> Result<Completion> {
460 let mut call_tools: Vec<ToolSpec> = Vec::new();
461 call_tools.push(Self::tool_search_spec().0);
462 call_tools.extend(eager_tools.iter().map(|(s, _)| s.clone()));
463 call_tools.extend_from_slice(loaded_tools);
464
465 let tx = stream_tx.clone().or_else(|| self.stream_tx.clone());
466 let completion = self
467 .stream_inner(messages, &call_tools, tx, cancel_token.clone())
468 .await?;
469
470 let search_call = completion
471 .tool_calls
472 .iter()
473 .find(|c| c.name == TOOL_SEARCH_TOOL_NAME);
474
475 let search_call = match search_call {
476 None => return Ok(completion),
477 Some(c) => c.clone(),
478 };
479
480 if round >= self.max_search_rounds {
481 tracing::warn!(
482 target: "recursive::llm",
483 round,
484 max = self.max_search_rounds,
485 "ToolSearchTool (openai/stream): hit max_search_rounds"
486 );
487 return Ok(completion);
488 }
489
490 let query = search_call
491 .arguments
492 .get("query")
493 .and_then(|v| v.as_str())
494 .unwrap_or("");
495 let names = self.search_engine.resolve(query, deferred_tools);
496 let matched: Vec<ToolSpec> = deferred_tools
497 .iter()
498 .filter(|(s, _)| names.contains(&s.name))
499 .map(|(s, _)| s.clone())
500 .collect();
501 let result_json = serde_json::to_string(&matched).unwrap_or_else(|_| "[]".into());
502
503 let mut next_messages: Vec<Message> = messages.to_vec();
504 next_messages.push(Message {
505 role: Role::Assistant,
506 content: completion.content.clone(),
507 tool_calls: completion.tool_calls.clone(),
508 tool_call_id: None,
509 reasoning_content: completion.reasoning_content.clone(),
510 is_compaction_summary: false,
511 });
512 next_messages.push(Message {
513 role: Role::Tool,
514 content: result_json,
515 tool_calls: vec![],
516 tool_call_id: Some(search_call.id.clone()),
517 reasoning_content: None,
518 is_compaction_summary: false,
519 });
520
521 let mut next_loaded = loaded_tools.to_vec();
522 next_loaded.extend_from_slice(&matched);
523
524 Box::pin(self.run_stream_search_loop(
525 &next_messages,
526 eager_tools,
527 deferred_tools,
528 &next_loaded,
529 round + 1,
530 stream_tx,
531 cancel_token,
532 ))
533 .await
534 }
535
536 async fn stream_inner(
537 &self,
538 messages: &[Message],
539 tools: &[ToolSpec],
540 stream_tx: Option<StreamSender>,
541 cancel_token: Option<tokio_util::sync::CancellationToken>,
542 ) -> Result<Completion> {
543 let mut body = build_request(
544 &self.model,
545 self.temperature,
546 self.max_tokens,
547 messages,
548 tools,
549 );
550 body["stream"] = Value::Bool(true);
551 body["stream_options"] = serde_json::json!({ "include_usage": true });
557 let url = format!("{}/chat/completions", self.base_url);
558
559 let mut attempt = 0;
563 loop {
564 tracing::debug!(target: "recursive::llm", request = %body, "POST {url} (stream)");
565 let result = self
566 .client
567 .post(&url)
568 .bearer_auth(&self.api_key)
569 .json(&body)
570 .send()
571 .await;
572
573 match result {
574 Ok(resp) => {
575 let status = resp.status();
576 if status.is_success() {
577 return self
578 .parse_sse_stream(resp, stream_tx.clone(), cancel_token.clone())
579 .await;
580 }
581 let text = resp.text().await?;
582 tracing::debug!(target: "recursive::llm", body = %text, "error response (stream)");
583 if let Some(backoff) =
584 self.retry
585 .backoff_for(attempt, Some(status.as_u16()), false)
586 {
587 tracing::warn!(
588 target: "recursive::llm",
589 attempt,
590 backoff_ms = backoff.as_millis(),
591 status = status.as_u16(),
592 "transient HTTP error, retrying (stream)"
593 );
594 tokio::time::sleep(backoff).await;
595 attempt += 1;
596 continue;
597 }
598 return Err(self.make_err(format!("HTTP {status}: {text}")));
599 }
600 Err(e) => {
601 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
602 tracing::warn!(
603 target: "recursive::llm",
604 attempt,
605 backoff_ms = backoff.as_millis(),
606 error = %e,
607 "network error, retrying (stream)"
608 );
609 tokio::time::sleep(backoff).await;
610 attempt += 1;
611 continue;
612 }
613 return Err(self.make_err(format!("request failed: {e}")));
614 }
615 }
616 }
617 }
618
619 async fn parse_sse_stream(
626 &self,
627 resp: reqwest::Response,
628 stream_tx: Option<StreamSender>,
629 cancel_token: Option<tokio_util::sync::CancellationToken>,
630 ) -> Result<Completion> {
631 let mut content = String::new();
632 let mut reasoning_content = String::new();
633 let mut tool_call_builders: HashMap<usize, (String, String, String)> = HashMap::new();
635 let mut finish_reason: Option<String> = None;
636 let mut usage: Option<TokenUsage> = None;
637
638 let mut byte_stream = resp.bytes_stream();
646 let mut incomplete: Vec<u8> = Vec::new();
647 let mut line_buf = String::new();
648
649 loop {
654 let chunk_result = if let Some(ct) = cancel_token.as_ref() {
655 tokio::select! {
656 biased;
657 _ = ct.cancelled() => {
658 let have_partial = !content.is_empty()
669 || !reasoning_content.is_empty()
670 || !tool_call_builders.is_empty();
671 if have_partial {
672 tracing::warn!(
673 target: "recursive::llm",
674 content_len = content.len(),
675 "SSE stream cancelled; returning partial completion"
676 );
677 return Ok(Self::build_completion(
678 content,
679 reasoning_content,
680 HashMap::new(), Some("interrupted".to_string()),
682 usage.take(),
683 ));
684 }
685 return Err(Error::Cancelled);
687 }
688 next = byte_stream.next() => next,
689 }
690 } else {
691 byte_stream.next().await
692 };
693 let chunk = match chunk_result {
694 Some(Ok(c)) => c,
695 Some(Err(e)) => {
696 let have_partial = !content.is_empty()
704 || !reasoning_content.is_empty()
705 || !tool_call_builders.is_empty();
706 if have_partial {
707 tracing::warn!(
708 target: "recursive::llm",
709 error = %e,
710 content_len = content.len(),
711 "SSE stream interrupted; returning partial completion"
712 );
713 return Ok(Self::build_completion(
714 content,
715 reasoning_content,
716 HashMap::new(), Some("interrupted".to_string()),
718 usage.take(),
719 ));
720 }
721 return Err(self.make_err(format!("SSE stream read error: {e}")));
722 }
723 None => break,
724 };
725 let combined: Vec<u8> = if incomplete.is_empty() {
727 chunk.to_vec()
728 } else {
729 let mut v = std::mem::take(&mut incomplete);
730 v.extend_from_slice(&chunk);
731 v
732 };
733 let valid_up_to = match std::str::from_utf8(&combined) {
735 Ok(_) => combined.len(),
736 Err(e) => e.valid_up_to(),
737 };
738 incomplete = combined[valid_up_to..].to_vec();
739 let text = unsafe { std::str::from_utf8_unchecked(&combined[..valid_up_to]) };
742 for ch in text.chars() {
743 if ch == '\n' {
744 let line = std::mem::take(&mut line_buf);
745 Self::process_sse_line(
746 &line,
747 &self.model,
748 &mut content,
749 &mut reasoning_content,
750 &mut tool_call_builders,
751 &mut finish_reason,
752 &mut usage,
753 &stream_tx,
754 )?;
755 } else if ch != '\r' {
756 line_buf.push(ch);
757 }
758 }
759 }
760 Self::warn_incomplete_utf8_tail(&incomplete);
763 if !line_buf.is_empty() {
765 Self::process_sse_line(
766 &line_buf,
767 &self.model,
768 &mut content,
769 &mut reasoning_content,
770 &mut tool_call_builders,
771 &mut finish_reason,
772 &mut usage,
773 &stream_tx,
774 )?;
775 }
776
777 Ok(Self::build_completion(
780 content,
781 reasoning_content,
782 tool_call_builders,
783 finish_reason,
784 usage,
785 ))
786 }
787
788 fn build_completion(
792 content: String,
793 reasoning_content: String,
794 mut tool_call_builders: HashMap<usize, (String, String, String)>,
795 finish_reason: Option<String>,
796 usage: Option<TokenUsage>,
797 ) -> Completion {
798 let mut sorted_indices: Vec<usize> = tool_call_builders.keys().copied().collect();
800 sorted_indices.sort_unstable();
801 let tool_calls: Vec<ToolCall> = sorted_indices
802 .into_iter()
803 .filter_map(|i| {
804 let (id, name, partial_json) = tool_call_builders.remove(&i)?;
805 if id.is_empty() && name.is_empty() {
806 return None;
807 }
808 let arguments = if partial_json.trim().is_empty() {
809 Value::Object(Default::default())
810 } else {
811 serde_json::from_str(&partial_json).unwrap_or(Value::String(partial_json))
812 };
813 Some(ToolCall {
814 id,
815 name,
816 arguments,
817 })
818 })
819 .collect();
820
821 Completion {
822 content,
823 tool_calls,
824 finish_reason,
825 usage,
826 reasoning_content: if reasoning_content.is_empty() {
827 None
828 } else {
829 Some(reasoning_content)
830 },
831 }
832 }
833
834 #[cfg_attr(test, mutants::skip)]
840 fn warn_incomplete_utf8_tail(incomplete: &[u8]) {
841 if !incomplete.is_empty() {
842 tracing::warn!(
843 target: "recursive::llm",
844 bytes = incomplete.len(),
845 "SSE stream ended with incomplete UTF-8 sequence; discarding tail bytes"
846 );
847 }
848 }
849
850 #[allow(clippy::too_many_arguments)]
853 fn process_sse_line(
854 line: &str,
855 provider: &str,
856 content: &mut String,
857 reasoning_content: &mut String,
858 tool_call_builders: &mut HashMap<usize, (String, String, String)>,
859 finish_reason: &mut Option<String>,
860 usage: &mut Option<TokenUsage>,
861 stream_tx: &Option<StreamSender>,
862 ) -> Result<()> {
863 let data = match line.strip_prefix("data: ") {
864 Some(d) => d,
865 None => return Ok(()),
866 };
867 if data.trim() == "[DONE]" {
868 return Ok(());
869 }
870
871 let chunk: Value = serde_json::from_str(data).map_err(|e| Error::Llm {
872 provider: provider.to_string(),
873 message: format!("SSE parse error: {e}; data: {data}"),
874 })?;
875
876 if let Some(choices) = chunk.get("choices").and_then(|c| c.as_array()) {
877 if let Some(choice) = choices.first() {
878 if let Some(delta) = choice.get("delta") {
879 if let Some(delta_content) = delta.get("content").and_then(|c| c.as_str()) {
880 if !delta_content.is_empty() {
881 content.push_str(delta_content);
882 if let Some(tx) = stream_tx {
883 let _ = tx.send(StreamChunk::Text(delta_content.to_string()));
884 }
885 }
886 }
887 if let Some(delta_reasoning) =
888 delta.get("reasoning_content").and_then(|c| c.as_str())
889 {
890 if !delta_reasoning.is_empty() {
891 reasoning_content.push_str(delta_reasoning);
892 if let Some(tx) = stream_tx {
893 let _ =
894 tx.send(StreamChunk::Reasoning(delta_reasoning.to_string()));
895 }
896 }
897 }
898 if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) {
900 for tc in tcs {
901 let idx =
902 tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
903 let entry = tool_call_builders
904 .entry(idx)
905 .or_insert_with(|| (String::new(), String::new(), String::new()));
906 if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
907 if !id.is_empty() {
908 entry.0 = id.to_string();
909 }
910 }
911 if let Some(func) = tc.get("function") {
912 if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
913 if !name.is_empty() {
914 entry.1 = name.to_string();
915 }
916 }
917 if let Some(args) = func.get("arguments").and_then(|v| v.as_str()) {
918 entry.2.push_str(args);
919 }
920 }
921 }
922 }
923 }
924 if let Some(fr) = choice.get("finish_reason").and_then(|f| f.as_str()) {
925 if !fr.is_empty() {
926 *finish_reason = Some(fr.to_string());
927 }
928 }
929 }
930 }
931
932 if let Some(u) = chunk.get("usage") {
933 let prompt = u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
934 let completion = u
935 .get("completion_tokens")
936 .and_then(|v| v.as_u64())
937 .unwrap_or(0) as u32;
938 let total = u.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
939 let cache_hit = u
942 .get("prompt_cache_hit_tokens")
943 .and_then(|v| v.as_u64())
944 .or_else(|| {
945 u.get("prompt_tokens_details")
946 .and_then(|d| d.get("cached_tokens"))
947 .and_then(|v| v.as_u64())
948 })
949 .unwrap_or(0) as u32;
950 let explicit_miss = u
951 .get("prompt_cache_miss_tokens")
952 .and_then(|v| v.as_u64())
953 .unwrap_or(0) as u32;
954 let cache_miss = if explicit_miss > 0 {
961 explicit_miss
962 } else if cache_hit > 0 {
963 prompt.saturating_sub(cache_hit)
964 } else {
965 0
966 };
967 let reasoning = u
968 .get("completion_tokens_details")
969 .and_then(|d| d.get("reasoning_tokens"))
970 .and_then(|v| v.as_u64())
971 .unwrap_or(0) as u32;
972 *usage = Some(TokenUsage {
973 prompt_tokens: prompt,
974 completion_tokens: completion,
975 total_tokens: total,
976 cache_hit_tokens: cache_hit,
977 cache_miss_tokens: cache_miss,
978 reasoning_tokens: reasoning,
979 });
980 }
981 Ok(())
982 }
983}
984
985fn build_request(
986 model: &str,
987 temperature: f64,
988 max_tokens: u32,
989 messages: &[Message],
990 tools: &[ToolSpec],
991) -> Value {
992 let mut req = serde_json::json!({
993 "model": model,
994 "temperature": temperature,
995 "max_tokens": max_tokens,
996 "messages": messages.iter().map(serialize_message).collect::<Vec<_>>(),
997 });
998 if !tools.is_empty() {
999 let tools_json: Vec<Value> = tools
1000 .iter()
1001 .map(|t| {
1002 serde_json::json!({
1003 "type": "function",
1004 "function": {
1005 "name": t.name,
1006 "description": t.description,
1007 "parameters": t.parameters,
1008 }
1009 })
1010 })
1011 .collect();
1012 req["tools"] = Value::Array(tools_json);
1013 req["tool_choice"] = Value::String("auto".into());
1014 }
1015 if model_wants_reasoning_split(model) {
1024 req["reasoning_split"] = Value::Bool(true);
1025 }
1026 req
1027}
1028
1029fn model_wants_reasoning_split(model: &str) -> bool {
1031 model
1032 .to_ascii_lowercase()
1033 .split(|c: char| !c.is_ascii_alphanumeric())
1034 .any(|part| part == "minimax")
1035}
1036
1037fn serialize_message(m: &Message) -> Value {
1038 let role = match m.role {
1039 Role::System => "system",
1040 Role::User => "user",
1041 Role::Assistant => "assistant",
1042 Role::Tool => "tool",
1043 };
1044 let mut obj = serde_json::Map::new();
1045 obj.insert("role".into(), Value::String(role.into()));
1046 obj.insert("content".into(), Value::String(m.content.clone()));
1047 if let Some(id) = &m.tool_call_id {
1048 obj.insert("tool_call_id".into(), Value::String(id.clone()));
1049 }
1050 if matches!(m.role, Role::Assistant) {
1053 if let Some(ref reasoning) = m.reasoning_content {
1054 obj.insert("reasoning_content".into(), Value::String(reasoning.clone()));
1055 }
1056 }
1057 if !m.tool_calls.is_empty() {
1058 let calls: Vec<Value> = m
1059 .tool_calls
1060 .iter()
1061 .map(|c| {
1062 serde_json::json!({
1063 "id": c.id,
1064 "type": "function",
1065 "function": {
1066 "name": c.name,
1067 "arguments": serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into()),
1068 }
1069 })
1070 })
1071 .collect();
1072 obj.insert("tool_calls".into(), Value::Array(calls));
1073 }
1074 Value::Object(obj)
1075}
1076
1077#[derive(Debug, Deserialize)]
1078struct ChatResponse {
1079 choices: Vec<ChatChoice>,
1080 #[serde(default)]
1081 usage: Option<ResponseUsage>,
1082}
1083
1084#[derive(Debug, Deserialize)]
1085struct ResponseUsage {
1086 #[serde(default)]
1087 prompt_tokens: Option<u32>,
1088 #[serde(default)]
1089 completion_tokens: Option<u32>,
1090 #[serde(default)]
1091 total_tokens: Option<u32>,
1092 #[serde(default)]
1096 prompt_cache_hit_tokens: Option<u32>,
1097 #[serde(default)]
1098 prompt_cache_miss_tokens: Option<u32>,
1099 #[serde(default)]
1103 prompt_tokens_details: Option<PromptTokensDetails>,
1104 #[serde(default)]
1110 completion_tokens_details: Option<CompletionTokensDetails>,
1111}
1112
1113#[derive(Debug, Default, Deserialize)]
1114struct PromptTokensDetails {
1115 #[serde(default)]
1116 cached_tokens: Option<u32>,
1117}
1118
1119#[derive(Debug, Default, Deserialize)]
1120struct CompletionTokensDetails {
1121 #[serde(default)]
1122 reasoning_tokens: Option<u32>,
1123}
1124
1125impl ResponseUsage {
1126 fn to_token_usage(&self) -> TokenUsage {
1127 let cache_hit = self
1130 .prompt_cache_hit_tokens
1131 .or_else(|| {
1132 self.prompt_tokens_details
1133 .as_ref()
1134 .and_then(|d| d.cached_tokens)
1135 })
1136 .unwrap_or(0);
1137
1138 let cache_miss = match self.prompt_cache_miss_tokens {
1149 Some(m) if m > 0 => m,
1150 _ if cache_hit > 0 => self.prompt_tokens.unwrap_or(0).saturating_sub(cache_hit),
1151 _ => 0,
1152 };
1153
1154 let reasoning_tokens = self
1155 .completion_tokens_details
1156 .as_ref()
1157 .and_then(|d| d.reasoning_tokens)
1158 .unwrap_or(0);
1159
1160 TokenUsage {
1161 reasoning_tokens,
1162 prompt_tokens: self.prompt_tokens.unwrap_or(0),
1163 completion_tokens: self.completion_tokens.unwrap_or(0),
1164 total_tokens: self.total_tokens.unwrap_or(0),
1165 cache_hit_tokens: cache_hit,
1166 cache_miss_tokens: cache_miss,
1167 }
1168 }
1169}
1170
1171#[derive(Debug, Deserialize)]
1172struct ChatChoice {
1173 message: ChatChoiceMessage,
1174 #[serde(default)]
1175 finish_reason: Option<String>,
1176}
1177
1178#[derive(Debug, Deserialize)]
1179struct ChatChoiceMessage {
1180 #[serde(default)]
1181 content: Option<String>,
1182 #[serde(default)]
1183 reasoning_content: Option<String>,
1184 #[serde(default)]
1185 tool_calls: Vec<RawToolCall>,
1186}
1187
1188#[derive(Debug, Deserialize, Serialize)]
1189struct RawToolCall {
1190 id: String,
1191 #[serde(default)]
1192 function: RawFunction,
1193}
1194
1195#[derive(Debug, Default, Deserialize, Serialize)]
1196struct RawFunction {
1197 #[serde(default)]
1198 name: String,
1199 #[serde(default)]
1200 arguments: String,
1201}
1202
1203fn parse_completion(choice: ChatChoice, usage: Option<ResponseUsage>) -> Completion {
1204 let content = choice.message.content.unwrap_or_default();
1205 let reasoning_content = choice.message.reasoning_content;
1206 let tool_calls = choice
1207 .message
1208 .tool_calls
1209 .into_iter()
1210 .map(|c| {
1211 let args: Value = if c.function.arguments.trim().is_empty() {
1212 Value::Object(Default::default())
1213 } else {
1214 serde_json::from_str(&c.function.arguments)
1215 .unwrap_or_else(|_| Value::String(c.function.arguments.clone()))
1216 };
1217 ToolCall {
1218 id: c.id,
1219 name: c.function.name,
1220 arguments: args,
1221 }
1222 })
1223 .collect();
1224 Completion {
1225 content,
1226 tool_calls,
1227 finish_reason: choice.finish_reason,
1228 usage: usage.map(|u| u.to_token_usage()),
1229 reasoning_content,
1230 }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236
1237 #[test]
1238 fn parses_plain_text_choice() {
1239 let raw = r#"{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}"#;
1240 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1241 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1242 assert_eq!(c.content, "hi");
1243 assert!(c.tool_calls.is_empty());
1244 assert_eq!(c.finish_reason.as_deref(), Some("stop"));
1245 assert!(c.usage.is_none());
1246 }
1247
1248 #[test]
1249 fn parses_tool_call_choice() {
1250 let raw = r#"{
1251 "choices":[{
1252 "message":{
1253 "role":"assistant",
1254 "content":null,
1255 "tool_calls":[{
1256 "id":"call_1",
1257 "type":"function",
1258 "function":{"name":"Read","arguments":"{\"path\":\"src/lib.rs\"}"}
1259 }]
1260 },
1261 "finish_reason":"tool_calls"
1262 }]
1263 }"#;
1264 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1265 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1266 assert_eq!(c.tool_calls.len(), 1);
1267 assert_eq!(c.tool_calls[0].name, "Read");
1268 assert_eq!(c.tool_calls[0].arguments["path"], "src/lib.rs");
1269 }
1270
1271 #[test]
1272 fn parses_usage_from_response() {
1273 let raw = r#"{
1274 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1275 "usage":{"prompt_tokens":41,"completion_tokens":7,"total_tokens":48}
1276 }"#;
1277 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1278 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1279 assert!(c.usage.is_some());
1280 let u = c.usage.unwrap();
1281 assert_eq!(u.prompt_tokens, 41);
1282 assert_eq!(u.completion_tokens, 7);
1283 assert_eq!(u.total_tokens, 48);
1284 }
1285
1286 #[test]
1287 fn parses_missing_usage_as_none() {
1288 let raw = r#"{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}"#;
1289 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1290 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1291 assert!(c.usage.is_none());
1292 }
1293
1294 #[test]
1295 fn parses_partial_usage_fills_zeros() {
1296 let raw = r#"{
1297 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1298 "usage":{"total_tokens":50}
1299 }"#;
1300 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1301 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1302 assert!(c.usage.is_some());
1303 let u = c.usage.unwrap();
1304 assert_eq!(u.prompt_tokens, 0);
1305 assert_eq!(u.completion_tokens, 0);
1306 assert_eq!(u.total_tokens, 50);
1307 assert_eq!(u.cache_hit_tokens, 0);
1308 assert_eq!(u.cache_miss_tokens, 0);
1309 }
1310
1311 #[test]
1312 fn parses_cache_fields_from_deepseek_usage() {
1313 let raw = r#"{
1314 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1315 "usage":{
1316 "prompt_tokens":100,
1317 "completion_tokens":50,
1318 "total_tokens":150,
1319 "prompt_cache_hit_tokens":60,
1320 "prompt_cache_miss_tokens":40
1321 }
1322 }"#;
1323 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1324 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1325 assert!(c.usage.is_some());
1326 let u = c.usage.unwrap();
1327 assert_eq!(u.prompt_tokens, 100);
1328 assert_eq!(u.completion_tokens, 50);
1329 assert_eq!(u.total_tokens, 150);
1330 assert_eq!(u.cache_hit_tokens, 60);
1331 assert_eq!(u.cache_miss_tokens, 40);
1332 }
1333
1334 #[test]
1335 fn parses_cache_fields_as_zero_when_absent() {
1336 let raw = r#"{
1337 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1338 "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}
1339 }"#;
1340 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1341 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1342 assert!(c.usage.is_some());
1343 let u = c.usage.unwrap();
1344 assert_eq!(u.cache_hit_tokens, 0);
1345 assert_eq!(u.cache_miss_tokens, 0);
1346 }
1347
1348 #[test]
1349 fn parses_minimax_cached_tokens_nested_under_prompt_tokens_details() {
1350 let raw = r#"{
1355 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1356 "usage":{
1357 "prompt_tokens":780,
1358 "completion_tokens":20,
1359 "total_tokens":800,
1360 "completion_tokens_details":{"reasoning_tokens":19},
1361 "prompt_tokens_details":{"cached_tokens":768}
1362 }
1363 }"#;
1364 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1365 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1366 let u = c.usage.expect("usage should be parsed");
1367 assert_eq!(u.prompt_tokens, 780);
1368 assert_eq!(u.completion_tokens, 20);
1369 assert_eq!(u.cache_hit_tokens, 768, "cached_tokens must populate hit");
1370 assert_eq!(
1372 u.cache_hit_tokens + u.cache_miss_tokens,
1373 u.prompt_tokens,
1374 "cache_hit + cache_miss must equal prompt_tokens"
1375 );
1376 assert_eq!(u.reasoning_tokens, 19);
1377 }
1378
1379 #[test]
1380 fn derives_cache_miss_when_only_hit_is_reported() {
1381 let raw = r#"{
1386 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1387 "usage":{
1388 "prompt_tokens":780,
1389 "completion_tokens":20,
1390 "total_tokens":800,
1391 "prompt_tokens_details":{"cached_tokens":768}
1392 }
1393 }"#;
1394 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1395 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1396 let u = c.usage.expect("usage should be parsed");
1397 assert_eq!(u.cache_hit_tokens, 768);
1398 assert_eq!(u.cache_miss_tokens, 12);
1399 }
1400
1401 #[test]
1402 fn explicit_zero_cache_miss_is_derived_not_trusted() {
1403 let raw = r#"{
1409 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1410 "usage":{
1411 "prompt_tokens":780,
1412 "completion_tokens":20,
1413 "total_tokens":800,
1414 "prompt_cache_hit_tokens":768,
1415 "prompt_cache_miss_tokens":0
1416 }
1417 }"#;
1418 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1419 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1420 let u = c.usage.expect("usage should be parsed");
1421 assert_eq!(u.cache_hit_tokens, 768);
1422 assert_eq!(
1423 u.cache_miss_tokens, 12,
1424 "explicit 0 miss must derive, not peg at 100%"
1425 );
1426 }
1427
1428 #[test]
1429 fn explicit_cache_miss_takes_precedence_over_derived() {
1430 let raw = r#"{
1436 "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1437 "usage":{
1438 "prompt_tokens":100,
1439 "completion_tokens":50,
1440 "total_tokens":150,
1441 "prompt_cache_hit_tokens":60,
1442 "prompt_cache_miss_tokens":40,
1443 "prompt_tokens_details":{"cached_tokens":999}
1444 }
1445 }"#;
1446 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1447 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1448 let u = c.usage.expect("usage should be parsed");
1449 assert_eq!(u.cache_hit_tokens, 60);
1451 assert_eq!(u.cache_miss_tokens, 40);
1454 }
1455
1456 #[test]
1457 fn parses_reasoning_tokens_in_non_streaming_path() {
1458 let raw = r#"{
1463 "choices":[{"message":{"role":"assistant","content":"answer"},"finish_reason":"stop"}],
1464 "usage":{
1465 "prompt_tokens":50,
1466 "completion_tokens":200,
1467 "total_tokens":250,
1468 "completion_tokens_details":{"reasoning_tokens":175}
1469 }
1470 }"#;
1471 let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1472 let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1473 let u = c.usage.expect("usage should be parsed");
1474 assert_eq!(u.completion_tokens, 200);
1475 assert_eq!(u.reasoning_tokens, 175);
1476 }
1477
1478 #[test]
1479 fn stream_usage_parses_minimax_nested_cache_and_reasoning() {
1480 let mut usage: Option<TokenUsage> = None;
1484 let mut content = String::new();
1485 let mut reasoning_content = String::new();
1486 let mut tool_call_builders: std::collections::HashMap<usize, (String, String, String)> =
1487 std::collections::HashMap::new();
1488 let mut finish_reason: Option<String> = None;
1489 let data = r#"data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":780,"completion_tokens":20,"total_tokens":800,"completion_tokens_details":{"reasoning_tokens":19},"prompt_tokens_details":{"cached_tokens":768}}}"#;
1490 OpenAiProvider::process_sse_line(
1491 data,
1492 "MiniMax-M3",
1493 &mut content,
1494 &mut reasoning_content,
1495 &mut tool_call_builders,
1496 &mut finish_reason,
1497 &mut usage,
1498 &None,
1499 )
1500 .unwrap();
1501 let u = usage.expect("usage should be set");
1502 assert_eq!(u.cache_hit_tokens, 768);
1503 assert_eq!(u.cache_miss_tokens, 12);
1504 assert_eq!(u.reasoning_tokens, 19);
1505 assert_eq!(content, "ok");
1506 }
1507
1508 #[test]
1509 fn serialises_assistant_with_tool_calls() {
1510 let msg = Message::assistant_with_tool_calls(
1511 "",
1512 vec![ToolCall {
1513 id: "abc".into(),
1514 name: "Write".into(),
1515 arguments: serde_json::json!({"path":"a","contents":"b"}),
1516 }],
1517 );
1518 let v = serialize_message(&msg);
1519 assert_eq!(v["tool_calls"][0]["function"]["name"], "Write");
1520 let args = v["tool_calls"][0]["function"]["arguments"]
1521 .as_str()
1522 .unwrap();
1523 let decoded: Value = serde_json::from_str(args).unwrap();
1524 assert_eq!(decoded["contents"], "b");
1525 }
1526
1527 #[derive(Debug)]
1530 struct AlwaysReturnsEngine(Vec<String>);
1531 impl ToolSearchEngine for AlwaysReturnsEngine {
1532 fn resolve(&self, _query: &str, _candidates: &[SpecWithHint]) -> Vec<String> {
1533 self.0.clone()
1534 }
1535 }
1536
1537 fn make_tool_spec(name: &str) -> ToolSpec {
1538 ToolSpec {
1539 name: name.to_string(),
1540 description: format!("{name} tool"),
1541 parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
1542 }
1543 }
1544
1545 #[tokio::test]
1546 async fn openai_search_loop_skips_when_no_deferred() {
1547 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1551 let addr = listener.local_addr().unwrap();
1552 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1553 let call_count_clone = call_count.clone();
1554
1555 std::thread::spawn(move || {
1556 use std::io::{Read, Write};
1557 loop {
1558 if let Ok((mut stream, _)) = listener.accept() {
1559 call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1560 let mut buf = [0u8; 8192];
1561 let _ = stream.read(&mut buf);
1562 let body = r#"{"choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}"#;
1563 write!(
1564 stream,
1565 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1566 body.len(),
1567 body
1568 )
1569 .unwrap();
1570 stream.flush().unwrap();
1571 }
1572 }
1573 });
1574 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1575
1576 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
1577 let eager = vec![(make_tool_spec("Read"), None)];
1578 let result = provider
1579 .complete_with_search(&[Message::user("hi")], &eager, &[])
1580 .await
1581 .unwrap();
1582 assert_eq!(result.content, "done");
1583 assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1584 }
1585
1586 #[tokio::test]
1587 async fn openai_search_loop_resolves_deferred_tool() {
1588 use std::sync::Mutex;
1593 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1594 let addr = listener.local_addr().unwrap();
1595 let captured_tools: std::sync::Arc<Mutex<Vec<String>>> =
1596 std::sync::Arc::new(Mutex::new(vec![]));
1597 let captured_clone = captured_tools.clone();
1598 let call_idx = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1599 let call_idx_clone = call_idx.clone();
1600
1601 std::thread::spawn(move || {
1602 use std::io::{Read, Write};
1603 loop {
1604 if let Ok((mut stream, _)) = listener.accept() {
1605 let idx = call_idx_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1606 let mut buf = [0u8; 65536];
1607 let n = stream.read(&mut buf).unwrap_or(0);
1608 let req = String::from_utf8_lossy(&buf[..n]);
1609 if let Some(start) = req.find("\r\n\r\n") {
1611 let body_str = req[start + 4..].trim();
1612 if let Ok(body) = serde_json::from_str::<serde_json::Value>(body_str) {
1613 if let Some(tools) = body["tools"].as_array() {
1614 let names: Vec<String> = tools
1615 .iter()
1616 .filter_map(|t| {
1617 t["function"]["name"].as_str().map(String::from)
1618 })
1619 .collect();
1620 *captured_clone.lock().unwrap() = names;
1621 }
1622 }
1623 }
1624 let resp = if idx == 0 {
1625 r#"{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"id":"ts1","type":"function","function":{"name":"ToolSearchTool","arguments":"{\"query\":\"MyTool\"}"}}]},"finish_reason":"tool_calls"}]}"#
1627 } else {
1628 r#"{"choices":[{"message":{"role":"assistant","content":"found it"},"finish_reason":"stop"}]}"#
1630 };
1631 write!(
1632 stream,
1633 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1634 resp.len(),
1635 resp
1636 )
1637 .unwrap();
1638 stream.flush().unwrap();
1639 }
1640 }
1641 });
1642 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1643
1644 let my_tool = make_tool_spec("MyTool");
1645 let deferred = vec![(my_tool.clone(), None)];
1646 let engine = Arc::new(AlwaysReturnsEngine(vec!["MyTool".to_string()]));
1647 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
1648 .unwrap()
1649 .with_search_engine(engine);
1650
1651 let result = provider
1652 .complete_with_search(&[Message::user("hi")], &[], &deferred)
1653 .await
1654 .unwrap();
1655 assert_eq!(result.content, "found it");
1656 let tools = captured_tools.lock().unwrap().clone();
1658 assert!(
1659 tools.contains(&"MyTool".to_string()),
1660 "second request should include MyTool, got: {tools:?}"
1661 );
1662 assert_eq!(call_idx.load(std::sync::atomic::Ordering::SeqCst), 2);
1663 }
1664
1665 #[tokio::test]
1666 async fn openai_search_loop_caps_at_max_rounds() {
1667 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1670 let addr = listener.local_addr().unwrap();
1671 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1672 let call_count_clone = call_count.clone();
1673
1674 std::thread::spawn(move || {
1675 use std::io::{Read, Write};
1676 loop {
1677 if let Ok((mut stream, _)) = listener.accept() {
1678 call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1679 let mut buf = [0u8; 65536];
1680 let _ = stream.read(&mut buf);
1681 let resp = r#"{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"id":"ts1","type":"function","function":{"name":"ToolSearchTool","arguments":"{\"query\":\"anything\"}"}}]},"finish_reason":"tool_calls"}]}"#;
1683 write!(
1684 stream,
1685 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1686 resp.len(),
1687 resp
1688 )
1689 .unwrap();
1690 stream.flush().unwrap();
1691 }
1692 }
1693 });
1694 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1695
1696 let deferred = vec![(make_tool_spec("SomeTool"), None)];
1697 let engine = Arc::new(AlwaysReturnsEngine(vec![])); let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
1699 .unwrap()
1700 .with_search_engine(engine);
1701
1702 let result = provider
1703 .complete_with_search(&[Message::user("hi")], &[], &deferred)
1704 .await
1705 .unwrap();
1706 let calls = call_count.load(std::sync::atomic::Ordering::SeqCst);
1708 assert_eq!(calls, 3 + 1, "expected {} calls, got {calls}", 3 + 1);
1709 assert!(!result.tool_calls.is_empty());
1711 }
1712
1713 #[test]
1714 fn builds_request_without_tools_omits_field() {
1715 let req = build_request("m", 0.2, 16384, &[Message::user("hi")], &[]);
1716 assert!(req.get("tools").is_none());
1717 assert_eq!(req["messages"][0]["role"], "user");
1718 assert_eq!(req["max_tokens"], 16384);
1719 assert!(
1720 req.get("reasoning_split").is_none(),
1721 "non-MiniMax models must not get reasoning_split"
1722 );
1723 }
1724
1725 #[test]
1726 fn builds_request_includes_max_tokens() {
1727 let req = build_request("m", 0.2, 1024, &[Message::user("hi")], &[]);
1728 assert_eq!(req["max_tokens"], 1024);
1729 }
1730
1731 #[test]
1732 fn builds_request_minimax_sets_reasoning_split() {
1733 for model in [
1734 "MiniMax-M3",
1735 "MiniMax-M2.7",
1736 "minimax-m3",
1737 "ab-MiniMax-test",
1738 ] {
1739 let req = build_request(model, 0.2, 1024, &[Message::user("hi")], &[]);
1740 assert_eq!(
1741 req.get("reasoning_split"),
1742 Some(&Value::Bool(true)),
1743 "model {model} should request reasoning_split"
1744 );
1745 }
1746 }
1747
1748 #[test]
1749 fn model_wants_reasoning_split_detects_minimax_token() {
1750 assert!(model_wants_reasoning_split("MiniMax-M3"));
1751 assert!(model_wants_reasoning_split("minimax-m2.7"));
1752 assert!(!model_wants_reasoning_split("deepseek-chat"));
1753 assert!(!model_wants_reasoning_split("gpt-4o"));
1754 assert!(!model_wants_reasoning_split("notminimax"));
1756 }
1757
1758 #[tokio::test]
1759 async fn stream_request_includes_include_usage_flag() {
1760 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1766 let addr = listener.local_addr().unwrap();
1767 let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
1768 let captured_clone = captured.clone();
1769
1770 std::thread::spawn(move || {
1771 let (mut stream, _) = listener.accept().unwrap();
1772 use std::io::{Read, Write};
1773 let mut buf = [0u8; 8192];
1774 let n = stream.read(&mut buf).unwrap();
1775 let request = String::from_utf8_lossy(&buf[..n]).to_string();
1776 if let Some(body_start) = request.find("\r\n\r\n") {
1777 let body = request[body_start + 4..].trim().to_string();
1778 *captured_clone.lock().unwrap() = body.clone();
1779 }
1780 let body = "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n\
1781data: [DONE]\n\n";
1782 write!(
1783 stream,
1784 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1785 body.len(),
1786 body,
1787 )
1788 .unwrap();
1789 stream.flush().unwrap();
1790 });
1791
1792 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1793
1794 let provider =
1795 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "MiniMax-M3").unwrap();
1796 let _ = provider
1797 .stream(&[Message::user("hi")], &[], None, None)
1798 .await
1799 .unwrap();
1800
1801 let body_str = captured.lock().unwrap().clone();
1802 let body: serde_json::Value =
1803 serde_json::from_str(&body_str).expect("captured body should be valid JSON");
1804 assert_eq!(body["stream"], true, "stream flag must be set");
1805 assert_eq!(
1806 body["stream_options"]["include_usage"], true,
1807 "stream_options.include_usage must be true so providers like MiniMax emit the usage chunk"
1808 );
1809 }
1810
1811 #[tokio::test]
1812 async fn error_includes_model_name_on_network_failure() {
1813 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1816 let addr = listener.local_addr().unwrap();
1817 drop(listener);
1818
1819 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1821
1822 let provider =
1823 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-model").unwrap();
1824 let err = provider
1825 .complete(&[Message::user("hi")], &[])
1826 .await
1827 .unwrap_err();
1828 let msg = err.to_string();
1829 assert!(
1830 msg.contains("test-model"),
1831 "error should contain model name: {msg}"
1832 );
1833 }
1834
1835 #[tokio::test]
1836 async fn error_includes_model_name_and_status_on_http_error() {
1837 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1839 let addr = listener.local_addr().unwrap();
1840
1841 std::thread::spawn(move || {
1843 let (mut stream, _) = listener.accept().unwrap();
1844 use std::io::{Read, Write};
1845 let mut buf = [0u8; 4096];
1846 let _ = stream.read(&mut buf);
1847 write!(
1848 stream,
1849 "HTTP/1.1 400 Bad Request\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
1850 )
1851 .unwrap();
1852 stream.flush().unwrap();
1853 });
1854
1855 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1857
1858 let provider =
1859 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-model-http").unwrap();
1860 let err = provider
1861 .complete(&[Message::user("hi")], &[])
1862 .await
1863 .unwrap_err();
1864 let msg = err.to_string();
1865 assert!(
1866 msg.contains("test-model-http"),
1867 "error should contain model name: {msg}"
1868 );
1869 assert!(
1870 msg.contains("400"),
1871 "error should contain HTTP status: {msg}"
1872 );
1873 }
1874
1875 #[tokio::test]
1876 async fn stream_concatenates_sse_chunks() {
1877 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1881 let addr = listener.local_addr().unwrap();
1882
1883 std::thread::spawn(move || {
1884 let (mut stream, _) = listener.accept().unwrap();
1885 use std::io::{Read, Write};
1886 let mut buf = [0u8; 4096];
1887 let _ = stream.read(&mut buf);
1888 let body = "\
1889data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\
1890data: {\"choices\":[{\"delta\":{\"content\":\" \"},\"finish_reason\":null}]}\n\n\
1891data: {\"choices\":[{\"delta\":{\"content\":\"World\"},\"finish_reason\":\"stop\"}]}\n\n\
1892data: [DONE]\n\n";
1893 write!(
1894 stream,
1895 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1896 body.len(),
1897 body,
1898 )
1899 .unwrap();
1900 stream.flush().unwrap();
1901 });
1902
1903 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1904
1905 let provider =
1906 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-stream-model").unwrap();
1907 let completion = provider
1908 .stream(&[Message::user("hi")], &[], None, None)
1909 .await
1910 .unwrap();
1911 assert_eq!(completion.content, "Hello World");
1912 assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
1913 }
1914
1915 #[tokio::test]
1916 async fn parse_sse_stream_returns_error_on_malformed_chunk() {
1917 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1926 let addr = listener.local_addr().unwrap();
1927
1928 std::thread::spawn(move || {
1929 let (mut stream, _) = listener.accept().unwrap();
1930 use std::io::{Read, Write};
1931 let mut buf = [0u8; 4096];
1932 let _ = stream.read(&mut buf);
1933 let body = "\
1934data: {not valid json
1935
1936data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}
1937
1938data: [DONE]
1939
1940";
1941 write!(
1942 stream,
1943 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1944 body.len(),
1945 body,
1946 )
1947 .unwrap();
1948 stream.flush().unwrap();
1949 });
1950
1951 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1952
1953 let provider =
1954 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-malformed-model")
1955 .unwrap();
1956 let result = tokio::time::timeout(
1959 std::time::Duration::from_secs(5),
1960 provider.stream(&[Message::user("hi")], &[], None, None),
1961 )
1962 .await
1963 .expect("SSE parser must not hang on malformed input");
1964
1965 let err = match result {
1966 Ok(_) => panic!("expected Err on malformed SSE chunk, got Ok"),
1967 Err(e) => e,
1968 };
1969 match err {
1970 Error::Llm { provider, message } => {
1971 assert!(
1972 message.contains("SSE parse error"),
1973 "error message should be diagnosable, got: {message}"
1974 );
1975 assert!(
1976 provider.contains("test-malformed-model"),
1977 "error should carry the provider/model name, got: {provider}"
1978 );
1979 }
1980 other => panic!("expected Error::Llm, got {other:?}"),
1981 }
1982 }
1983
1984 #[tokio::test]
1985 async fn parse_sse_stream_returns_partial_on_mid_stream_error() {
1986 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1994 let addr = listener.local_addr().unwrap();
1995
1996 std::thread::spawn(move || {
1997 let (mut stream, _) = listener.accept().unwrap();
1998 use std::io::{Read, Write};
1999 let mut buf = [0u8; 4096];
2000 let _ = stream.read(&mut buf);
2001 let body = "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n";
2002 write!(
2003 stream,
2004 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2005 9999,
2006 body,
2007 )
2008 .unwrap();
2009 stream.flush().unwrap();
2010 });
2013
2014 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2015
2016 let provider =
2017 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-stream-model").unwrap();
2018 let completion = provider
2019 .stream(&[Message::user("hi")], &[], None, None)
2020 .await
2021 .expect("partial content must be returned, not Err");
2022 assert_eq!(
2023 completion.content, "partial",
2024 "accumulated delta must survive the mid-stream read error",
2025 );
2026 assert_eq!(
2027 completion.finish_reason.as_deref(),
2028 Some("interrupted"),
2029 "partial completion must carry the interrupted sentinel",
2030 );
2031 assert!(
2032 completion.tool_calls.is_empty(),
2033 "half-received tool calls must be dropped from a partial completion",
2034 );
2035 }
2036
2037 #[tokio::test]
2038 async fn stream_fallback_delegates_to_complete() {
2039 use crate::llm::MockProvider;
2042 let provider = MockProvider::new(vec![super::Completion {
2043 content: "fallback works".to_string(),
2044 tool_calls: vec![],
2045 finish_reason: Some("stop".to_string()),
2046 usage: None,
2047 reasoning_content: None,
2048 }]);
2049 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2050 let completion = provider
2051 .stream(&[Message::user("hi")], &[], Some(tx), None)
2052 .await
2053 .unwrap();
2054 assert_eq!(completion.content, "fallback works");
2055 let delta = rx.recv().await.unwrap();
2057 assert_eq!(delta, StreamChunk::Text("fallback works".to_string()));
2058 }
2059
2060 #[test]
2061 fn policy_retries_5xx_with_exponential_backoff() {
2062 let policy = RetryPolicy::default(); assert_eq!(
2065 policy.backoff_for(0, Some(503), false),
2066 Some(Duration::from_secs(1))
2067 );
2068 assert_eq!(
2070 policy.backoff_for(1, Some(500), false),
2071 Some(Duration::from_secs(2))
2072 );
2073 assert_eq!(policy.backoff_for(2, Some(500), false), None);
2075 }
2076
2077 #[test]
2078 fn policy_retries_network_errors() {
2079 let policy = RetryPolicy::default();
2080 assert_eq!(
2082 policy.backoff_for(0, None, true),
2083 Some(Duration::from_secs(1))
2084 );
2085 }
2086
2087 #[test]
2088 fn policy_does_not_retry_4xx() {
2089 let policy = RetryPolicy::default();
2090 assert_eq!(policy.backoff_for(0, Some(400), false), None);
2092 assert_eq!(policy.backoff_for(0, Some(401), false), None);
2093 assert_eq!(policy.backoff_for(0, Some(404), false), None);
2094 assert!(policy.backoff_for(0, Some(429), false).is_some());
2096 }
2097
2098 #[test]
2099 fn policy_caps_backoff_at_max() {
2100 let policy = RetryPolicy {
2101 max_retries: 10,
2102 initial_backoff: Duration::from_secs(1),
2103 max_backoff: Duration::from_secs(3),
2104 };
2105 assert_eq!(
2107 policy.backoff_for(5, Some(500), false),
2108 Some(Duration::from_secs(3))
2109 );
2110 }
2111
2112 #[tokio::test]
2113 async fn openai_structured_includes_schema_in_request_body() {
2114 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2118 let addr = listener.local_addr().unwrap();
2119 let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
2120 let captured_clone = captured.clone();
2121
2122 std::thread::spawn(move || {
2123 let (mut stream, _) = listener.accept().unwrap();
2124 use std::io::{Read, Write};
2125 let mut buf = [0u8; 8192];
2126 let n = stream.read(&mut buf).unwrap();
2127 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2128 if let Some(body_start) = request.find("\r\n\r\n") {
2130 let body = request[body_start + 4..].trim().to_string();
2131 *captured_clone.lock().unwrap() = body.clone();
2132 }
2133 let response_body = r#"{"choices":[{"message":{"role":"assistant","content":"{\"answer\":42}"},"finish_reason":"stop"}],"usage":null}"#;
2134 write!(
2135 stream,
2136 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2137 response_body.len(),
2138 response_body,
2139 )
2140 .unwrap();
2141 stream.flush().unwrap();
2142 });
2143
2144 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2145
2146 let provider =
2147 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-structured-model")
2148 .unwrap();
2149
2150 let schema = serde_json::json!({
2151 "type": "object",
2152 "properties": {
2153 "answer": {"type": "integer"}
2154 },
2155 "required": ["answer"]
2156 });
2157
2158 let req = StructuredRequest {
2159 messages: vec![Message::user("what is 6 times 7?".to_string())],
2160 schema: schema.clone(),
2161 schema_name: "math_answer".to_string(),
2162 };
2163
2164 let _ = provider.complete_structured(req).await;
2165
2166 let body_str = captured.lock().unwrap().clone();
2168 let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
2169
2170 let rf = body.get("response_format").unwrap();
2172 assert_eq!(rf["type"], "json_schema");
2173 assert_eq!(rf["json_schema"]["name"], "math_answer");
2174 assert_eq!(rf["json_schema"]["strict"], true);
2175 assert_eq!(rf["json_schema"]["schema"], schema);
2176 }
2177
2178 #[tokio::test]
2179 async fn openai_structured_parses_response_json() {
2180 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2183 let addr = listener.local_addr().unwrap();
2184
2185 std::thread::spawn(move || {
2186 let (mut stream, _) = listener.accept().unwrap();
2187 use std::io::{Read, Write};
2188 let mut buf = [0u8; 8192];
2189 let _ = stream.read(&mut buf);
2190 let response_body = r#"{"choices":[{"message":{"role":"assistant","content":"{\"summary\":\"test\",\"kept_facts\":[\"a\",\"b\"]}"},"finish_reason":"stop"}],"usage":null}"#;
2192 write!(
2193 stream,
2194 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2195 response_body.len(),
2196 response_body,
2197 )
2198 .unwrap();
2199 stream.flush().unwrap();
2200 });
2201
2202 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2203
2204 let provider =
2205 OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-structured-model")
2206 .unwrap();
2207
2208 let schema = serde_json::json!({
2209 "type": "object",
2210 "properties": {
2211 "summary": {"type": "string"},
2212 "kept_facts": {"type": "array", "items": {"type": "string"}}
2213 },
2214 "required": ["summary", "kept_facts"]
2215 });
2216
2217 let req = StructuredRequest {
2218 messages: vec![Message::user("summarize".to_string())],
2219 schema,
2220 schema_name: "summary".to_string(),
2221 };
2222
2223 let result = provider.complete_structured(req).await;
2224 assert!(result.is_ok(), "got error: {:?}", result.err());
2225 let value = result.unwrap();
2226 assert_eq!(value["summary"], "test");
2227 assert_eq!(value["kept_facts"][0], "a");
2228 assert_eq!(value["kept_facts"][1], "b");
2229 }
2230
2231 #[test]
2234 fn serialize_message_system_role() {
2235 let msg = Message::system("You are helpful.".to_string());
2237 let v = serialize_message(&msg);
2238 assert_eq!(v["role"].as_str(), Some("system"));
2239 assert_eq!(v["content"].as_str(), Some("You are helpful."));
2240 }
2241
2242 #[test]
2243 fn serialize_message_user_role() {
2244 let msg = Message::user("Hello".to_string());
2246 let v = serialize_message(&msg);
2247 assert_eq!(v["role"].as_str(), Some("user"));
2248 }
2249
2250 #[test]
2251 fn serialize_message_tool_role_includes_tool_call_id() {
2252 let msg = Message::tool_result("tc-abc".to_string(), "result text".to_string());
2254 let v = serialize_message(&msg);
2255 assert_eq!(v["role"].as_str(), Some("tool"));
2256 assert_eq!(v["tool_call_id"].as_str(), Some("tc-abc"));
2257 }
2258
2259 #[test]
2260 fn serialize_message_assistant_includes_reasoning_content() {
2261 let mut msg = Message::assistant("answer".to_string());
2264 msg.reasoning_content = Some("thinking step".to_string());
2265 let v = serialize_message(&msg);
2266 assert_eq!(v["reasoning_content"].as_str(), Some("thinking step"));
2267 }
2268
2269 #[test]
2270 fn serialize_message_non_assistant_omits_reasoning_content() {
2271 let mut msg = Message::user("hello".to_string());
2274 msg.reasoning_content = Some("should not appear".to_string());
2275 let v = serialize_message(&msg);
2276 assert!(
2277 v.get("reasoning_content").is_none()
2278 || v["reasoning_content"] == serde_json::Value::Null,
2279 "non-assistant messages must not include reasoning_content"
2280 );
2281 }
2282
2283 #[tokio::test]
2284 async fn post_json_with_retry_gives_up_after_max_empty_body_retries() {
2285 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2290 let addr = listener.local_addr().unwrap();
2291 std::thread::spawn(move || {
2292 use std::io::{Read, Write};
2293 loop {
2294 if let Ok((mut stream, _)) = listener.accept() {
2295 let mut buf = [0u8; 4096];
2296 let _ = stream.read(&mut buf);
2297 write!(
2298 stream,
2299 "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2300 )
2301 .unwrap();
2302 stream.flush().unwrap();
2303 }
2304 }
2305 });
2306 tokio::time::sleep(Duration::from_millis(50)).await;
2307
2308 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2309 .unwrap()
2310 .with_retry_policy(RetryPolicy {
2311 max_retries: 2,
2312 initial_backoff: Duration::from_millis(5),
2313 max_backoff: Duration::from_millis(10),
2314 });
2315 let result = provider
2316 .post_json_with_retry(
2317 &format!("http://{addr}/chat"),
2318 &serde_json::json!({}),
2319 "test",
2320 )
2321 .await;
2322 assert!(
2323 result.is_err(),
2324 "must give up after max_retries empty-body responses"
2325 );
2326 }
2327
2328 #[tokio::test]
2329 async fn post_json_with_retry_gives_up_after_max_transient_http_retries() {
2330 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2333 let addr = listener.local_addr().unwrap();
2334 std::thread::spawn(move || {
2335 use std::io::{Read, Write};
2336 loop {
2337 if let Ok((mut stream, _)) = listener.accept() {
2338 let mut buf = [0u8; 4096];
2339 let _ = stream.read(&mut buf);
2340 let body = r#"{"error":"boom"}"#;
2341 write!(
2342 stream,
2343 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2344 body.len(),
2345 body
2346 )
2347 .unwrap();
2348 stream.flush().unwrap();
2349 }
2350 }
2351 });
2352 tokio::time::sleep(Duration::from_millis(50)).await;
2353
2354 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2355 .unwrap()
2356 .with_retry_policy(RetryPolicy {
2357 max_retries: 2,
2358 initial_backoff: Duration::from_millis(5),
2359 max_backoff: Duration::from_millis(10),
2360 });
2361 let result = provider
2362 .post_json_with_retry(
2363 &format!("http://{addr}/chat"),
2364 &serde_json::json!({}),
2365 "test",
2366 )
2367 .await;
2368 assert!(
2369 result.is_err(),
2370 "must give up after max_retries transient HTTP failures"
2371 );
2372 }
2373
2374 #[tokio::test]
2375 async fn post_json_with_retry_gives_up_after_max_network_retries() {
2376 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2379 let addr = listener.local_addr().unwrap();
2380 std::thread::spawn(move || {
2381 for _ in 0..3 {
2382 if let Ok((stream, _)) = listener.accept() {
2383 drop(stream);
2384 }
2385 }
2386 });
2387 tokio::time::sleep(Duration::from_millis(50)).await;
2388
2389 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2390 .unwrap()
2391 .with_retry_policy(RetryPolicy {
2392 max_retries: 2,
2393 initial_backoff: Duration::from_millis(5),
2394 max_backoff: Duration::from_millis(10),
2395 });
2396 let result = provider
2397 .post_json_with_retry(
2398 &format!("http://{addr}/chat"),
2399 &serde_json::json!({}),
2400 "test",
2401 )
2402 .await;
2403 assert!(
2404 result.is_err(),
2405 "must give up after max_retries network failures"
2406 );
2407 }
2408
2409 #[tokio::test]
2410 async fn stream_inner_gives_up_after_max_transient_http_retries() {
2411 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2414 let addr = listener.local_addr().unwrap();
2415 std::thread::spawn(move || {
2416 use std::io::{Read, Write};
2417 loop {
2418 if let Ok((mut stream, _)) = listener.accept() {
2419 let mut buf = [0u8; 4096];
2420 let _ = stream.read(&mut buf);
2421 let body = r#"{"error":"boom"}"#;
2422 write!(
2423 stream,
2424 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2425 body.len(),
2426 body
2427 )
2428 .unwrap();
2429 stream.flush().unwrap();
2430 }
2431 }
2432 });
2433 tokio::time::sleep(Duration::from_millis(50)).await;
2434
2435 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2436 .unwrap()
2437 .with_retry_policy(RetryPolicy {
2438 max_retries: 2,
2439 initial_backoff: Duration::from_millis(5),
2440 max_backoff: Duration::from_millis(10),
2441 });
2442 let result = provider
2443 .stream_inner(&[Message::user("hi")], &[], None, None)
2444 .await;
2445 assert!(
2446 result.is_err(),
2447 "stream_inner must give up after max_retries transient HTTP failures"
2448 );
2449 }
2450
2451 #[tokio::test]
2452 async fn stream_inner_gives_up_after_max_network_retries() {
2453 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2456 let addr = listener.local_addr().unwrap();
2457 std::thread::spawn(move || {
2458 for _ in 0..3 {
2459 if let Ok((stream, _)) = listener.accept() {
2460 drop(stream);
2461 }
2462 }
2463 });
2464 tokio::time::sleep(Duration::from_millis(50)).await;
2465
2466 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2467 .unwrap()
2468 .with_retry_policy(RetryPolicy {
2469 max_retries: 2,
2470 initial_backoff: Duration::from_millis(5),
2471 max_backoff: Duration::from_millis(10),
2472 });
2473 let result = provider
2474 .stream_inner(&[Message::user("hi")], &[], None, None)
2475 .await;
2476 assert!(
2477 result.is_err(),
2478 "stream_inner must give up after max_retries network failures"
2479 );
2480 }
2481
2482 #[tokio::test]
2483 async fn stream_with_search_without_deferred_delegates_to_stream() {
2484 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2488 let addr = listener.local_addr().unwrap();
2489 std::thread::spawn(move || {
2490 let (mut stream, _) = listener.accept().unwrap();
2491 use std::io::{Read, Write};
2492 let mut buf = [0u8; 4096];
2493 let _ = stream.read(&mut buf);
2494 let body = "data: {\"choices\":[{\"delta\":{\"content\":\"hello search\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n";
2495 write!(
2496 stream,
2497 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2498 body.len(),
2499 body,
2500 )
2501 .unwrap();
2502 stream.flush().unwrap();
2503 });
2504 tokio::time::sleep(Duration::from_millis(50)).await;
2505
2506 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2507 let completion = provider
2508 .stream_with_search(&[Message::user("hi")], &[], &[], None, None)
2509 .await
2510 .expect("stream_with_search without deferred tools should succeed");
2511 assert_eq!(completion.content, "hello search");
2512 }
2513
2514 #[tokio::test]
2515 async fn stream_search_loop_resolves_round_trip_and_stops() {
2516 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2522 let addr = listener.local_addr().unwrap();
2523 std::thread::spawn(move || {
2524 use std::io::{Read, Write};
2525 loop {
2526 if let Ok((mut stream, _)) = listener.accept() {
2527 let mut buf = [0u8; 65536];
2528 let _ = stream.read(&mut buf);
2529 let req = String::from_utf8_lossy(&buf);
2530 let body = if req.contains("\"role\":\"tool\"") {
2531 "data: {\"choices\":[{\"delta\":{\"content\":\"final answer\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
2533 } else {
2534 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"ts1\",\"function\":{\"name\":\"ToolSearchTool\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n"
2536 };
2537 write!(
2538 stream,
2539 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2540 body.len(),
2541 body,
2542 )
2543 .unwrap();
2544 stream.flush().unwrap();
2545 }
2546 }
2547 });
2548 tokio::time::sleep(Duration::from_millis(50)).await;
2549
2550 let deferred = vec![(make_tool_spec("MyTool"), None)];
2551 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2552 .unwrap()
2553 .with_search_engine(Arc::new(AlwaysReturnsEngine(vec!["MyTool".to_string()])));
2554 let completion = provider
2555 .stream_with_search(&[Message::user("hi")], &[], &deferred, None, None)
2556 .await
2557 .expect("stream search loop should complete");
2558 assert_eq!(completion.content, "final answer");
2559 assert!(
2560 completion.tool_calls.is_empty(),
2561 "round-1 completion must not carry a search call"
2562 );
2563 }
2564
2565 #[tokio::test]
2566 async fn stream_search_loop_honors_zero_max_rounds() {
2567 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2571 let addr = listener.local_addr().unwrap();
2572 std::thread::spawn(move || {
2573 use std::io::{Read, Write};
2574 loop {
2575 if let Ok((mut stream, _)) = listener.accept() {
2576 let mut buf = [0u8; 65536];
2577 let _ = stream.read(&mut buf);
2578 let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"ts1\",\"function\":{\"name\":\"ToolSearchTool\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n";
2579 write!(
2580 stream,
2581 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2582 body.len(),
2583 body,
2584 )
2585 .unwrap();
2586 stream.flush().unwrap();
2587 }
2588 }
2589 });
2590 tokio::time::sleep(Duration::from_millis(50)).await;
2591
2592 let deferred = vec![(make_tool_spec("MyTool"), None)];
2593 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2594 .unwrap()
2595 .with_search_engine(Arc::new(AlwaysReturnsEngine(vec![])))
2596 .with_max_search_rounds(0);
2597 let completion = provider
2598 .stream_with_search(&[Message::user("hi")], &[], &deferred, None, None)
2599 .await
2600 .expect("round-0 search call must be returned as-is");
2601 assert!(
2602 !completion.tool_calls.is_empty(),
2603 "the round-0 search-call completion must be returned when max rounds is 0"
2604 );
2605 }
2606
2607 #[tokio::test]
2608 async fn stream_search_loop_stops_after_max_rounds_with_persistent_search_calls() {
2609 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2614 let addr = listener.local_addr().unwrap();
2615 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2616 let call_count_clone = call_count.clone();
2617 std::thread::spawn(move || {
2618 use std::io::{Read, Write};
2619 loop {
2620 if let Ok((mut stream, _)) = listener.accept() {
2621 call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2622 let mut buf = [0u8; 65536];
2623 let _ = stream.read(&mut buf);
2624 let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"ts1\",\"function\":{\"name\":\"ToolSearchTool\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n";
2625 write!(
2626 stream,
2627 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2628 body.len(),
2629 body,
2630 )
2631 .unwrap();
2632 stream.flush().unwrap();
2633 }
2634 }
2635 });
2636 tokio::time::sleep(Duration::from_millis(50)).await;
2637
2638 let deferred = vec![(make_tool_spec("MyTool"), None)];
2639 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2640 .unwrap()
2641 .with_search_engine(Arc::new(AlwaysReturnsEngine(vec![])));
2642 let completion = provider
2643 .stream_with_search(&[Message::user("hi")], &[], &deferred, None, None)
2644 .await
2645 .expect("loop must terminate at max_search_rounds");
2646 assert!(
2647 !completion.tool_calls.is_empty(),
2648 "the last search-call completion must be returned when rounds are exhausted"
2649 );
2650 assert_eq!(
2651 call_count.load(std::sync::atomic::Ordering::SeqCst),
2652 4,
2653 "rounds 0..=3 = 4 stream calls with default max_search_rounds=3"
2654 );
2655 }
2656
2657 #[tokio::test]
2658 async fn parse_sse_stream_returns_partial_when_only_reasoning_accumulated() {
2659 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2664 let addr = listener.local_addr().unwrap();
2665 std::thread::spawn(move || {
2666 let (mut stream, _) = listener.accept().unwrap();
2667 use std::io::{Read, Write};
2668 let mut buf = [0u8; 4096];
2669 let _ = stream.read(&mut buf);
2670 let body = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Let me think\"},\"finish_reason\":null}]}\n\n";
2671 write!(
2672 stream,
2673 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2674 9999,
2675 body,
2676 )
2677 .unwrap();
2678 stream.flush().unwrap();
2679 });
2680 tokio::time::sleep(Duration::from_millis(50)).await;
2681
2682 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2683 let completion = provider
2684 .stream(&[Message::user("hi")], &[], None, None)
2685 .await
2686 .expect("reasoning-only partial must be returned, not Err");
2687 assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
2688 assert_eq!(completion.content, "");
2689 assert_eq!(
2690 completion.reasoning_content.as_deref(),
2691 Some("Let me think"),
2692 "accumulated reasoning must survive the mid-stream error"
2693 );
2694 }
2695
2696 #[tokio::test]
2697 async fn parse_sse_stream_returns_partial_when_only_tool_call_started() {
2698 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2704 let addr = listener.local_addr().unwrap();
2705 std::thread::spawn(move || {
2706 let (mut stream, _) = listener.accept().unwrap();
2707 use std::io::{Read, Write};
2708 let mut buf = [0u8; 4096];
2709 let _ = stream.read(&mut buf);
2710 let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"Read\",\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\"}}]},\"finish_reason\":null}]}\n\n";
2711 write!(
2712 stream,
2713 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2714 9999,
2715 body,
2716 )
2717 .unwrap();
2718 stream.flush().unwrap();
2719 });
2720 tokio::time::sleep(Duration::from_millis(50)).await;
2721
2722 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2723 let completion = provider
2724 .stream(&[Message::user("hi")], &[], None, None)
2725 .await
2726 .expect("tool-call-only partial must be returned, not Err");
2727 assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
2728 assert_eq!(completion.content, "");
2729 assert!(
2730 completion.tool_calls.is_empty(),
2731 "half-received tool calls must be dropped from a partial completion"
2732 );
2733 }
2734
2735 #[tokio::test]
2736 async fn parse_sse_stream_processes_trailing_line_without_newline() {
2737 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2740 let addr = listener.local_addr().unwrap();
2741 std::thread::spawn(move || {
2742 let (mut stream, _) = listener.accept().unwrap();
2743 use std::io::{Read, Write};
2744 let mut buf = [0u8; 4096];
2745 let _ = stream.read(&mut buf);
2746 let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}";
2748 write!(
2749 stream,
2750 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2751 body.len(),
2752 body,
2753 )
2754 .unwrap();
2755 stream.flush().unwrap();
2756 });
2757 tokio::time::sleep(Duration::from_millis(50)).await;
2758
2759 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2760 let completion = provider
2761 .stream(&[Message::user("hi")], &[], None, None)
2762 .await
2763 .expect("stream should complete");
2764 assert_eq!(
2765 completion.content, "Hello world",
2766 "trailing line without newline must still be processed"
2767 );
2768 }
2769
2770 #[tokio::test]
2771 async fn build_completion_keeps_tool_call_with_empty_id_but_name() {
2772 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2776 let addr = listener.local_addr().unwrap();
2777 std::thread::spawn(move || {
2778 let (mut stream, _) = listener.accept().unwrap();
2779 use std::io::{Read, Write};
2780 let mut buf = [0u8; 4096];
2781 let _ = stream.read(&mut buf);
2782 let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Read\",\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n";
2784 write!(
2785 stream,
2786 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2787 body.len(),
2788 body,
2789 )
2790 .unwrap();
2791 stream.flush().unwrap();
2792 });
2793 tokio::time::sleep(Duration::from_millis(50)).await;
2794
2795 let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2796 let completion = provider
2797 .stream(&[Message::user("hi")], &[], None, None)
2798 .await
2799 .expect("stream should complete");
2800 assert_eq!(
2801 completion.tool_calls.len(),
2802 1,
2803 "tool call with empty id but real name must be kept"
2804 );
2805 assert_eq!(completion.tool_calls[0].name, "Read");
2806 }
2807
2808 #[test]
2809 fn process_sse_line_accumulates_non_empty_reasoning() {
2810 let mut content = String::new();
2813 let mut reasoning = String::new();
2814 let mut tcb = std::collections::HashMap::new();
2815 let mut fr = None;
2816 let mut usage = None;
2817 OpenAiProvider::process_sse_line(
2818 "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"abc\"},\"finish_reason\":null}]}",
2819 "m",
2820 &mut content,
2821 &mut reasoning,
2822 &mut tcb,
2823 &mut fr,
2824 &mut usage,
2825 &None,
2826 )
2827 .unwrap();
2828 assert_eq!(reasoning, "abc");
2829 }
2830
2831 #[test]
2832 fn process_sse_line_sets_tool_call_id_and_name() {
2833 let mut content = String::new();
2836 let mut reasoning = String::new();
2837 let mut tcb = std::collections::HashMap::new();
2838 let mut fr = None;
2839 let mut usage = None;
2840 OpenAiProvider::process_sse_line(
2841 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_9\",\"function\":{\"name\":\"Read\",\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}",
2842 "m",
2843 &mut content,
2844 &mut reasoning,
2845 &mut tcb,
2846 &mut fr,
2847 &mut usage,
2848 &None,
2849 )
2850 .unwrap();
2851 let entry = tcb.get(&0).expect("tool call builder entry must exist");
2852 assert_eq!(entry.0, "call_9");
2853 assert_eq!(entry.1, "Read");
2854 }
2855
2856 #[test]
2857 fn process_sse_line_cache_miss_prefers_explicit() {
2858 let mut content = String::new();
2861 let mut reasoning = String::new();
2862 let mut tcb = std::collections::HashMap::new();
2863 let mut fr = None;
2864 let mut usage = None;
2865 OpenAiProvider::process_sse_line(
2866 "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12,\"prompt_cache_hit_tokens\":3,\"prompt_cache_miss_tokens\":5}}",
2867 "m",
2868 &mut content,
2869 &mut reasoning,
2870 &mut tcb,
2871 &mut fr,
2872 &mut usage,
2873 &None,
2874 )
2875 .unwrap();
2876 let u = usage.expect("usage must be set");
2877 assert_eq!(u.cache_miss_tokens, 5);
2878 }
2879
2880 #[test]
2881 fn process_sse_line_cache_miss_zero_when_no_hit() {
2882 let mut content = String::new();
2885 let mut reasoning = String::new();
2886 let mut tcb = std::collections::HashMap::new();
2887 let mut fr = None;
2888 let mut usage = None;
2889 OpenAiProvider::process_sse_line(
2890 "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12,\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":0}}",
2891 "m",
2892 &mut content,
2893 &mut reasoning,
2894 &mut tcb,
2895 &mut fr,
2896 &mut usage,
2897 &None,
2898 )
2899 .unwrap();
2900 let u = usage.expect("usage must be set");
2901 assert_eq!(u.cache_miss_tokens, 0);
2902 }
2903
2904 #[test]
2905 fn to_token_usage_cache_miss_prefers_explicit_positive() {
2906 let ru = ResponseUsage {
2910 prompt_tokens: Some(10),
2911 completion_tokens: Some(2),
2912 total_tokens: Some(12),
2913 prompt_cache_hit_tokens: Some(0),
2914 prompt_cache_miss_tokens: Some(5),
2915 prompt_tokens_details: None,
2916 completion_tokens_details: None,
2917 };
2918 let tu = ru.to_token_usage();
2919 assert_eq!(tu.cache_miss_tokens, 5);
2920 }
2921}