1use std::collections::HashMap;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use futures::StreamExt;
13use serde::{Deserialize, Serialize};
14
15use supercode_interchange::{ChatMessage, FunctionCall, Role, ToolCall};
16
17use crate::{CachePlan, ChatRequest, Result, RuntimeError as Error, ToolSchema, Usage};
18
19const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
22
23const READ_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
29
30const MAX_RETRIES: u32 = 2;
34
35const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(500);
38
39#[derive(Debug, Clone, Copy)]
45#[doc(hidden)]
46pub struct HttpOptions {
47 pub(crate) connect_timeout: Duration,
48 pub(crate) read_idle_timeout: Duration,
49 pub(crate) max_retries: u32,
50 pub(crate) retry_backoff_base: Duration,
51}
52
53impl Default for HttpOptions {
54 fn default() -> Self {
55 HttpOptions {
56 connect_timeout: CONNECT_TIMEOUT,
57 read_idle_timeout: READ_IDLE_TIMEOUT,
58 max_retries: MAX_RETRIES,
59 retry_backoff_base: RETRY_BACKOFF_BASE,
60 }
61 }
62}
63
64impl HttpOptions {
65 #[doc(hidden)]
78 pub fn from_retry_config(
79 enabled: bool,
80 max_retries: Option<u32>,
81 base_delay_ms: Option<u64>,
82 ) -> HttpOptions {
83 let base = HttpOptions::default();
84 HttpOptions {
85 max_retries: if enabled {
86 max_retries.unwrap_or(base.max_retries)
87 } else {
88 0
89 },
90 retry_backoff_base: base_delay_ms
91 .map(Duration::from_millis)
92 .unwrap_or(base.retry_backoff_base),
93 ..base
94 }
95 }
96}
97
98pub(crate) fn build_request_body(req: &ChatRequest, stream: bool) -> serde_json::Value {
102 use serde_json::json;
103 let mut body = json!({
104 "model": req.model,
105 "messages": req.messages,
106 "stream": stream,
107 });
108 let obj = body.as_object_mut().unwrap();
109 if !req.tools.is_empty() {
110 obj.insert(
111 "tools".into(),
112 serde_json::to_value(req.tools.iter().map(WireTool::from).collect::<Vec<_>>()).unwrap(),
113 );
114 }
115 if let Some(t) = req.temperature {
116 obj.insert("temperature".into(), json!(t));
117 }
118 if let Some(m) = req.max_tokens {
119 obj.insert("max_tokens".into(), json!(m));
120 }
121 if let Some(e) = &req.effort {
122 obj.insert("reasoning_effort".into(), json!(e));
123 }
124 if let Some(rf) = &req.response_format {
125 obj.insert("response_format".into(), rf.clone());
126 }
127 if stream {
128 obj.insert("stream_options".into(), json!({"include_usage": true}));
129 }
130 for (k, v) in &req.extra_body {
132 obj.insert(k.clone(), v.clone());
133 }
134 body
135}
136
137#[doc(hidden)]
163pub fn apply_cache_plan(
164 messages: &[ChatMessage],
165 plan: CachePlan,
166 imported_prefix_len: Option<usize>,
167) -> Vec<ChatMessage> {
168 let mut out = messages.to_vec();
169 if !matches!(plan, CachePlan::ImportedPrefix) {
170 return out;
171 }
172 let Some(len) = imported_prefix_len.filter(|&n| n > 0) else {
173 return out;
174 };
175 let last = len - 1;
176 let mut targets = vec![0usize];
177 if last != 0 {
178 targets.push(last);
179 }
180 for idx in targets {
181 if let Some(msg) = out.get_mut(idx) {
182 annotate_cache_breakpoint(msg);
183 }
184 }
185 out
186}
187
188fn annotate_cache_breakpoint(msg: &mut ChatMessage) {
191 let cache_control = serde_json::json!({"type": "ephemeral"});
192 if let Some(parts) = msg.content_parts.as_mut() {
193 if let Some(text_part) = parts
195 .iter_mut()
196 .rev()
197 .find(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("text"))
198 {
199 if let Some(obj) = text_part.as_object_mut() {
200 obj.insert("cache_control".to_string(), cache_control);
201 }
202 }
203 return;
204 }
205 let text = msg.content.take().unwrap_or_default();
206 msg.content_parts = Some(vec![serde_json::json!({
207 "type": "text",
208 "text": text,
209 "cache_control": cache_control,
210 })]);
211}
212
213#[doc(hidden)]
228pub fn tier_change_is_cache_bust(previous: Option<u64>, current: u64) -> bool {
229 previous.is_some_and(|p| p != current)
230}
231
232pub(crate) const CACHE_TTL_SECS: i64 = 300;
239
240pub(crate) const CACHE_MISS_RATIO_THRESHOLD: f64 = 0.10;
246
247pub(crate) const CACHE_STALE_DISPROVE_RATIO_THRESHOLD: f64 = 1.0 - CACHE_MISS_RATIO_THRESHOLD;
265
266#[doc(hidden)]
284pub fn is_anthropic_family_model(model: &str) -> bool {
285 model.starts_with("anthropic/") || model.starts_with("claude-") || model.starts_with("claude/")
286}
287
288#[derive(Debug, Clone, Copy, PartialEq)]
291#[doc(hidden)]
292pub enum CacheColdReason {
293 Stale {
298 idle_secs: i64,
300 },
301 Miss {
306 cached_tokens: u64,
308 prompt_tokens: u64,
310 },
311}
312
313impl CacheColdReason {
314 #[doc(hidden)]
316 pub fn message(&self) -> String {
317 match self {
318 CacheColdReason::Stale { idle_secs } => format!(
319 "cache likely cold — this turn was sent {}m{:02}s after the cache was last \
320 refreshed (Anthropic's ephemeral prompt cache expires after 5m idle) — this \
321 turn likely paid full input cost for the cached prefix",
322 idle_secs / 60,
323 idle_secs % 60,
324 ),
325 CacheColdReason::Miss {
326 cached_tokens,
327 prompt_tokens,
328 } => format!(
329 "unexpected cache miss — only {cached_tokens}/{prompt_tokens} prompt tokens \
330 were served from cache this turn even though reuse was expected — this turn \
331 likely paid full input cost for the cached prefix",
332 ),
333 }
334 }
335}
336
337#[doc(hidden)]
385pub fn cache_cold_reason(
386 will_annotate: bool,
387 cache_established: bool,
388 idle_secs: Option<i64>,
389 usage: &Usage,
390) -> Option<CacheColdReason> {
391 if !will_annotate {
392 return None;
393 }
394 if let Some(idle_secs) = idle_secs {
395 if idle_secs >= CACHE_TTL_SECS {
396 let disproven_by_usage = usage
397 .prompt_tokens_details
398 .filter(|_| usage.prompt_tokens > 0)
399 .is_some_and(|details| {
400 details.cached_tokens as f64 / usage.prompt_tokens as f64
401 >= CACHE_STALE_DISPROVE_RATIO_THRESHOLD
402 });
403 if !disproven_by_usage {
404 return Some(CacheColdReason::Stale { idle_secs });
405 }
406 }
407 }
408 if !cache_established {
409 return None;
412 }
413 let details = usage.prompt_tokens_details?;
414 if usage.prompt_tokens == 0 {
415 return None;
418 }
419 let ratio = details.cached_tokens as f64 / usage.prompt_tokens as f64;
420 if ratio < CACHE_MISS_RATIO_THRESHOLD {
421 return Some(CacheColdReason::Miss {
422 cached_tokens: details.cached_tokens,
423 prompt_tokens: usage.prompt_tokens,
424 });
425 }
426 None
427}
428
429#[async_trait]
432pub trait Provider: Send + Sync {
433 async fn complete(
436 &self,
437 req: &ChatRequest,
438 on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
439 ) -> Result<(ChatMessage, Usage)>;
440}
441
442pub struct OpenAiProvider {
445 client: reqwest::Client,
446 base_url: String,
447 api_key: String,
448 extra_headers: HashMap<String, String>,
449 http_options: HttpOptions,
450}
451
452impl OpenAiProvider {
453 pub fn new(
455 base_url: impl Into<String>,
456 api_key: impl Into<String>,
457 extra_headers: HashMap<String, String>,
458 ) -> Self {
459 Self::new_with_options(base_url, api_key, extra_headers, HttpOptions::default())
460 }
461
462 #[doc(hidden)]
467 pub fn new_with_options(
468 base_url: impl Into<String>,
469 api_key: impl Into<String>,
470 extra_headers: HashMap<String, String>,
471 http_options: HttpOptions,
472 ) -> Self {
473 OpenAiProvider {
474 client: reqwest::Client::builder()
475 .connect_timeout(http_options.connect_timeout)
476 .read_timeout(http_options.read_idle_timeout)
477 .build()
478 .expect("static reqwest client config cannot fail"),
479 base_url: base_url.into(),
480 api_key: api_key.into(),
481 extra_headers,
482 http_options,
483 }
484 }
485
486 fn endpoint(&self) -> String {
487 format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
488 }
489
490 async fn send_with_retry(&self, wire: &serde_json::Value) -> Result<reqwest::Response> {
497 let mut attempt = 0u32;
498 loop {
499 let mut builder = self
500 .client
501 .post(self.endpoint())
502 .bearer_auth(&self.api_key)
503 .header("Content-Type", "application/json");
504 for (k, v) in &self.extra_headers {
505 builder = builder.header(k, v);
506 }
507
508 let sent = builder.json(wire).send().await;
509 let (retryable, result): (bool, Result<reqwest::Response>) = match sent {
510 Err(e) => (true, Err(Error::from(e))),
511 Ok(resp) => {
512 let status = resp.status();
513 if status.is_success() {
514 (false, Ok(resp))
515 } else if status.is_server_error() {
516 let body = resp.text().await.unwrap_or_default();
517 (
518 true,
519 Err(Error::Provider {
520 status: status.as_u16(),
521 body: truncate(&body, 2000),
522 }),
523 )
524 } else {
525 let body = resp.text().await.unwrap_or_default();
526 (
527 false,
528 Err(Error::Provider {
529 status: status.as_u16(),
530 body: truncate(&body, 2000),
531 }),
532 )
533 }
534 }
535 };
536
537 if !retryable || attempt >= self.http_options.max_retries {
538 return result;
539 }
540 let backoff = self.http_options.retry_backoff_base * 2u32.pow(attempt);
541 tokio::time::sleep(backoff).await;
542 attempt += 1;
543 }
544 }
545}
546
547#[async_trait]
548impl Provider for OpenAiProvider {
549 async fn complete(
550 &self,
551 req: &ChatRequest,
552 on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
553 ) -> Result<(ChatMessage, Usage)> {
554 let wire = build_request_body(req, true);
555
556 let resp = self.send_with_retry(&wire).await?;
557
558 let mut acc = Accumulator::default();
559 let mut buf: Vec<u8> = Vec::new();
565 let mut deltas: Vec<String> = Vec::new();
566 let mut stream = resp.bytes_stream();
567 while let Some(chunk) = stream.next().await {
568 let bytes = chunk?;
569 buf.extend_from_slice(&bytes);
570 drain_sse_lines(&mut buf, &mut acc, &mut deltas)?;
571 for d in deltas.drain(..) {
572 on_delta(&d);
573 }
574 }
575 let tail = String::from_utf8_lossy(&buf);
577 if !tail.trim().is_empty() {
578 handle_sse_line(tail.trim(), &mut acc, &mut deltas)?;
579 for d in deltas.drain(..) {
580 on_delta(&d);
581 }
582 }
583
584 Ok((acc.to_message(), acc_usage(&acc)))
585 }
586}
587
588#[derive(Default)]
591struct Accumulator {
592 content: String,
593 tool_calls: Vec<ToolCallAccum>,
594 usage: Usage,
595}
596
597#[derive(Default)]
598struct ToolCallAccum {
599 id: String,
600 name: String,
601 arguments: String,
602}
603
604impl Accumulator {
605 fn ensure(&mut self, index: usize) -> &mut ToolCallAccum {
606 while self.tool_calls.len() <= index {
607 self.tool_calls.push(ToolCallAccum::default());
608 }
609 &mut self.tool_calls[index]
610 }
611
612 fn to_message(&self) -> ChatMessage {
613 let calls: Vec<ToolCall> = self
614 .tool_calls
615 .iter()
616 .filter(|c| !c.id.is_empty() || !c.name.is_empty())
617 .map(|c| ToolCall {
618 id: c.id.clone(),
619 kind: "function".to_string(),
620 function: FunctionCall {
621 name: c.name.clone(),
622 arguments: c.arguments.clone(),
623 },
624 })
625 .collect();
626 ChatMessage {
627 role: Role::Assistant,
628 content: (!self.content.is_empty()).then(|| self.content.clone()),
629 content_parts: None,
630 tool_calls: (!calls.is_empty()).then_some(calls),
631 tool_call_id: None,
632 name: None,
633 metadata: Default::default(),
634 }
635 }
636}
637
638fn acc_usage(acc: &Accumulator) -> Usage {
639 acc.usage.clone()
640}
641
642fn drain_sse_lines(
643 buf: &mut Vec<u8>,
644 acc: &mut Accumulator,
645 deltas: &mut Vec<String>,
646) -> Result<()> {
647 while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
648 let line: Vec<u8> = buf.drain(..=pos).collect();
649 let line = String::from_utf8_lossy(&line);
650 handle_sse_line(line.trim(), acc, deltas)?;
651 }
652 Ok(())
653}
654
655fn handle_sse_line(line: &str, acc: &mut Accumulator, deltas: &mut Vec<String>) -> Result<()> {
656 let Some(data) = line.strip_prefix("data:") else {
657 return Ok(());
658 };
659 let data = data.trim();
660 if data.is_empty() || data == "[DONE]" {
661 return Ok(());
662 }
663 let chunk: StreamChunk = match serde_json::from_str(data) {
664 Ok(c) => c,
665 Err(_) => return Ok(()), };
667 if let Some(u) = chunk.usage {
668 acc.usage = u;
669 }
670 for choice in chunk.choices {
671 if let Some(text) = choice.delta.content {
672 if !text.is_empty() {
673 acc.content.push_str(&text);
674 deltas.push(text);
675 }
676 }
677 for tc in choice.delta.tool_calls.unwrap_or_default() {
678 let slot = acc.ensure(tc.index);
679 if let Some(id) = tc.id {
680 slot.id = id;
681 }
682 if let Some(f) = tc.function {
683 if let Some(name) = f.name {
684 slot.name.push_str(&name);
685 }
686 if let Some(args) = f.arguments {
687 slot.arguments.push_str(&args);
688 }
689 }
690 }
691 }
692 Ok(())
693}
694
695fn truncate(s: &str, max: usize) -> String {
696 if s.len() <= max {
697 s.to_string()
698 } else {
699 let mut end = max;
702 while end > 0 && !s.is_char_boundary(end) {
703 end -= 1;
704 }
705 format!("{}…", &s[..end])
706 }
707}
708
709#[derive(Serialize)]
712struct WireTool<'a> {
713 #[serde(rename = "type")]
714 kind: &'static str,
715 function: WireFunction<'a>,
716}
717
718#[derive(Serialize)]
719struct WireFunction<'a> {
720 name: &'a str,
721 description: &'a str,
722 parameters: &'a serde_json::Value,
723}
724
725impl<'a> From<&'a ToolSchema> for WireTool<'a> {
726 fn from(t: &'a ToolSchema) -> Self {
727 WireTool {
728 kind: "function",
729 function: WireFunction {
730 name: &t.name,
731 description: &t.description,
732 parameters: &t.parameters,
733 },
734 }
735 }
736}
737
738#[derive(Deserialize)]
739struct StreamChunk {
740 #[serde(default)]
741 choices: Vec<StreamChoice>,
742 #[serde(default)]
743 usage: Option<Usage>,
744}
745
746#[derive(Deserialize)]
747struct StreamChoice {
748 delta: Delta,
749}
750
751#[derive(Deserialize)]
752struct Delta {
753 #[serde(default)]
754 content: Option<String>,
755 #[serde(default)]
756 tool_calls: Option<Vec<ToolCallDelta>>,
757}
758
759#[derive(Deserialize)]
760struct ToolCallDelta {
761 #[serde(default)]
762 index: usize,
763 #[serde(default)]
764 id: Option<String>,
765 #[serde(default)]
766 function: Option<FnDelta>,
767}
768
769#[derive(Deserialize)]
770struct FnDelta {
771 #[serde(default)]
772 name: Option<String>,
773 #[serde(default)]
774 arguments: Option<String>,
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780 use crate::PromptTokensDetails;
781 use supercode_interchange::ChatMessage;
782
783 #[test]
784 fn request_body_includes_effort_format_and_passthrough() {
785 let mut req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
786 req.effort = Some("high".into());
787 req.response_format =
788 Some(serde_json::json!({"type": "json_schema", "json_schema": {"name": "x"}}));
789 req.extra_body.insert(
790 "cache_control".into(),
791 serde_json::json!({"type": "ephemeral"}),
792 );
793 req.extra_body.insert(
794 "provider".into(),
795 serde_json::json!({"order": ["anthropic"]}),
796 );
797
798 let body = build_request_body(&req, false);
799 assert_eq!(body["model"], "m");
800 assert_eq!(body["reasoning_effort"], "high");
801 assert_eq!(body["response_format"]["type"], "json_schema");
802 assert_eq!(body["cache_control"]["type"], "ephemeral");
803 assert_eq!(body["provider"]["order"][0], "anthropic");
804 assert!(body.get("stream_options").is_none());
806 }
807
808 #[test]
809 fn extra_body_overrides_modeled_fields() {
810 let mut req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
811 req.max_tokens = Some(100);
812 req.extra_body
813 .insert("max_tokens".into(), serde_json::json!(999));
814 let body = build_request_body(&req, true);
815 assert_eq!(body["max_tokens"], 999, "extra_body wins");
816 assert_eq!(body["stream_options"]["include_usage"], true);
817 }
818
819 #[test]
828 fn cache_plan_annotates_system_and_last_imported_message_only() {
829 let messages = vec![
830 ChatMessage::system("sys"),
831 ChatMessage::user("u1"),
832 ChatMessage::assistant("a1"),
833 ChatMessage::user("u2"),
834 ];
835 let mut req = ChatRequest::new("m", messages);
836 req.messages = apply_cache_plan(&req.messages, crate::CachePlan::ImportedPrefix, Some(3));
837
838 let body = build_request_body(&req, false);
839 let msgs = body["messages"].as_array().unwrap();
840 assert_eq!(msgs.len(), 4, "annotation must not change message count");
841
842 assert_eq!(
843 msgs[0]["content"][0]["cache_control"]["type"], "ephemeral",
844 "breakpoint 1: system message"
845 );
846 assert_eq!(
847 msgs[2]["content"][0]["cache_control"]["type"], "ephemeral",
848 "breakpoint 2: last message of the imported prefix (a1)"
849 );
850 assert_eq!(
851 msgs[2]["content"][0]["text"], "a1",
852 "annotated text must be byte-identical to the original content"
853 );
854
855 for (i, m) in msgs.iter().enumerate() {
857 if i == 0 || i == 2 {
858 continue;
859 }
860 let has_cc = match &m["content"] {
861 serde_json::Value::Array(parts) => {
862 parts.iter().any(|p| p.get("cache_control").is_some())
863 }
864 serde_json::Value::String(_) => false,
865 _ => false,
866 };
867 assert!(!has_cc, "message {i} must not carry cache_control: {m:?}");
868 }
869 }
870
871 #[test]
872 fn cache_plan_off_never_annotates() {
873 let messages = vec![ChatMessage::system("sys"), ChatMessage::user("u1")];
874 let out = apply_cache_plan(&messages, crate::CachePlan::Off, Some(2));
875 assert_eq!(out[0].content_parts, None);
876 assert_eq!(out[1].content_parts, None);
877 }
878
879 #[test]
880 fn tier_change_is_cache_bust_truth_table() {
881 assert!(!tier_change_is_cache_bust(None, 42));
883 assert!(!tier_change_is_cache_bust(Some(42), 42));
885 assert!(tier_change_is_cache_bust(Some(42), 7));
887 }
888
889 #[test]
890 fn cache_plan_dedupes_when_prefix_is_only_the_system_message() {
891 let messages = vec![ChatMessage::system("sys"), ChatMessage::user("u1")];
894 let out = apply_cache_plan(&messages, crate::CachePlan::ImportedPrefix, Some(1));
895 assert!(out[0].content_parts.is_some());
896 assert_eq!(out[1].content_parts, None);
897 }
898
899 #[test]
900 fn cache_plan_annotates_last_text_part_of_already_multimodal_message() {
901 let imported_last = ChatMessage::user_with_images("caption", &["https://x/y.png".into()]);
902 assert_eq!(
904 imported_last.content_parts.as_ref().unwrap()[0]["type"],
905 "text"
906 );
907 let messages = vec![ChatMessage::system("sys"), imported_last];
908 let out = apply_cache_plan(&messages, crate::CachePlan::ImportedPrefix, Some(2));
909 let parts = out[1].content_parts.as_ref().unwrap();
910 assert_eq!(parts[0]["cache_control"]["type"], "ephemeral");
911 assert_eq!(parts[0]["text"], "caption");
912 assert!(
913 parts[1].get("cache_control").is_none(),
914 "the image_url part must not be annotated"
915 );
916 }
917
918 #[test]
922 fn usage_parses_prompt_tokens_details_cached_tokens() {
923 let acc = drain(&[
924 r#"data: {"choices":[{"delta":{"content":"hi"}}]}"#,
925 r#"data: {"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":90}}}"#,
926 "data: [DONE]",
927 ]);
928 assert_eq!(acc.usage.prompt_tokens, 100);
929 let details = acc.usage.prompt_tokens_details.expect("details present");
930 assert_eq!(details.cached_tokens, 90);
931 }
932
933 fn warm_usage() -> Usage {
936 Usage {
939 prompt_tokens: 1000,
940 completion_tokens: 20,
941 total_tokens: 1020,
942 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 950 }),
943 }
944 }
945
946 fn cold_usage() -> Usage {
947 Usage {
949 prompt_tokens: 1000,
950 completion_tokens: 20,
951 total_tokens: 1020,
952 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 3 }),
953 }
954 }
955
956 fn moderate_usage() -> Usage {
963 Usage {
964 prompt_tokens: 1000,
965 completion_tokens: 20,
966 total_tokens: 1020,
967 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 500 }),
968 }
969 }
970
971 #[test]
975 fn cache_cold_reason_never_fires_when_not_annotated() {
976 assert_eq!(
977 cache_cold_reason(false, true, Some(10_000), &cold_usage()),
978 None
979 );
980 assert_eq!(cache_cold_reason(false, false, None, &cold_usage()), None);
981 }
982
983 #[test]
989 fn cache_cold_reason_first_annotated_request_never_reports_miss() {
990 assert_eq!(
991 cache_cold_reason(true, false, Some(1), &cold_usage()),
992 None,
993 "first write: a near-zero cache-read ratio is expected, not a miss"
994 );
995 }
996
997 #[test]
1001 fn cache_cold_reason_silent_on_warm_back_to_back_turn() {
1002 assert_eq!(cache_cold_reason(true, true, Some(5), &warm_usage()), None);
1003 assert_eq!(cache_cold_reason(true, true, None, &warm_usage()), None);
1006 }
1007
1008 #[test]
1017 fn cache_cold_reason_fires_stale_on_first_turn_of_a_resumed_idle_session() {
1018 assert_eq!(
1019 cache_cold_reason(true, false, Some(20 * 60), &cold_usage()),
1020 Some(CacheColdReason::Stale { idle_secs: 20 * 60 })
1021 );
1022 }
1023
1024 #[test]
1038 fn cache_cold_reason_stale_suppressed_when_usage_disproves_it() {
1039 assert_eq!(
1040 cache_cold_reason(true, false, Some(20 * 60), &warm_usage()),
1041 None,
1042 "cache_established == false, but usage still disproves staleness"
1043 );
1044 assert_eq!(
1045 cache_cold_reason(true, true, Some(CACHE_TTL_SECS), &warm_usage()),
1046 None,
1047 "cache_established == true, at the TTL boundary, usage disproves staleness"
1048 );
1049 }
1050
1051 #[test]
1058 fn cache_cold_reason_stale_disprove_threshold_boundary() {
1059 let at_bar = Usage {
1060 prompt_tokens: 1000,
1061 completion_tokens: 1,
1062 total_tokens: 1001,
1063 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 900 }), };
1065 assert_eq!(
1066 cache_cold_reason(true, false, Some(CACHE_TTL_SECS), &at_bar),
1067 None,
1068 "exactly at the disprove bar suppresses Stale"
1069 );
1070
1071 let just_under = Usage {
1072 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 899 }),
1073 ..at_bar
1074 };
1075 assert_eq!(
1076 cache_cold_reason(true, false, Some(CACHE_TTL_SECS), &just_under),
1077 Some(CacheColdReason::Stale {
1078 idle_secs: CACHE_TTL_SECS
1079 }),
1080 "one token under the disprove bar must not suppress Stale"
1081 );
1082 }
1083
1084 #[test]
1090 fn cache_cold_reason_stale_not_suppressed_by_ambiguous_usage() {
1091 assert_eq!(
1092 cache_cold_reason(true, false, Some(20 * 60), &moderate_usage()),
1093 Some(CacheColdReason::Stale { idle_secs: 20 * 60 })
1094 );
1095 }
1096
1097 #[test]
1101 fn cache_cold_reason_stale_not_suppressed_by_missing_usage_details() {
1102 let no_details = Usage {
1103 prompt_tokens: 1000,
1104 completion_tokens: 20,
1105 total_tokens: 1020,
1106 prompt_tokens_details: None,
1107 };
1108 assert_eq!(
1109 cache_cold_reason(true, false, Some(20 * 60), &no_details),
1110 Some(CacheColdReason::Stale { idle_secs: 20 * 60 })
1111 );
1112 }
1113
1114 #[test]
1120 fn cache_cold_reason_fires_stale_at_ttl_boundary() {
1121 assert_eq!(
1122 cache_cold_reason(true, true, Some(CACHE_TTL_SECS), &moderate_usage()),
1123 Some(CacheColdReason::Stale {
1124 idle_secs: CACHE_TTL_SECS
1125 })
1126 );
1127 assert_eq!(
1128 cache_cold_reason(true, true, Some(CACHE_TTL_SECS - 1), &moderate_usage()),
1129 None,
1130 "one second under the TTL must not fire"
1131 );
1132 }
1133
1134 #[test]
1137 fn cache_cold_reason_fires_miss_on_low_ratio_inside_ttl() {
1138 assert_eq!(
1139 cache_cold_reason(true, true, Some(1), &cold_usage()),
1140 Some(CacheColdReason::Miss {
1141 cached_tokens: 3,
1142 prompt_tokens: 1000,
1143 })
1144 );
1145 }
1146
1147 #[test]
1150 fn cache_cold_reason_ratio_threshold_is_exclusive() {
1151 let at_threshold = Usage {
1152 prompt_tokens: 1000,
1153 completion_tokens: 1,
1154 total_tokens: 1001,
1155 prompt_tokens_details: Some(PromptTokensDetails {
1156 cached_tokens: 100, }),
1158 };
1159 assert_eq!(cache_cold_reason(true, true, Some(1), &at_threshold), None);
1160
1161 let just_under = Usage {
1162 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 99 }),
1163 ..at_threshold
1164 };
1165 assert!(cache_cold_reason(true, true, Some(1), &just_under).is_some());
1166 }
1167
1168 #[test]
1172 fn cache_cold_reason_no_verdict_without_usage_details() {
1173 let usage = Usage {
1174 prompt_tokens: 1000,
1175 completion_tokens: 5,
1176 total_tokens: 1005,
1177 prompt_tokens_details: None,
1178 };
1179 assert_eq!(cache_cold_reason(true, true, Some(1), &usage), None);
1180 }
1181
1182 #[test]
1185 fn cache_cold_reason_no_verdict_on_zero_prompt_tokens() {
1186 let usage = Usage {
1187 prompt_tokens: 0,
1188 completion_tokens: 5,
1189 total_tokens: 5,
1190 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 0 }),
1191 };
1192 assert_eq!(cache_cold_reason(true, true, Some(1), &usage), None);
1193 }
1194
1195 #[test]
1201 fn is_anthropic_family_model_recognizes_vendor_prefixed_slugs() {
1202 assert!(is_anthropic_family_model("anthropic/claude-opus-4-8"));
1203 assert!(is_anthropic_family_model("anthropic/claude-sonnet-4-6"));
1204 assert!(is_anthropic_family_model("anthropic/claude-haiku-4-5"));
1205 }
1206
1207 #[test]
1211 fn is_anthropic_family_model_recognizes_bare_claude_slugs() {
1212 assert!(is_anthropic_family_model("claude-opus-4-8"));
1213 assert!(is_anthropic_family_model("claude-3-5-sonnet-20241022"));
1214 }
1215
1216 #[test]
1220 fn is_anthropic_family_model_rejects_other_known_vendors() {
1221 assert!(!is_anthropic_family_model("openai/gpt-5"));
1222 assert!(!is_anthropic_family_model("openai/gpt-5.5"));
1223 assert!(!is_anthropic_family_model("google/gemini-2.5-pro"));
1224 assert!(!is_anthropic_family_model("deepseek/deepseek-v4-pro"));
1225 assert!(!is_anthropic_family_model("meta-llama/llama-4-maverick"));
1226 }
1227
1228 #[test]
1231 fn is_anthropic_family_model_does_not_assume_unknown_slugs() {
1232 assert!(!is_anthropic_family_model("my-custom-local-model"));
1233 assert!(!is_anthropic_family_model(""));
1234 }
1235
1236 #[test]
1237 fn truncate_never_splits_a_codepoint() {
1238 let s = "é".repeat(2000); let out = truncate(&s, 2001); assert!(out.ends_with('…'));
1242 assert!(out.len() <= 2001 + '…'.len_utf8());
1243 }
1244
1245 fn drain(lines: &[&str]) -> Accumulator {
1247 let mut acc = Accumulator::default();
1248 let mut deltas = Vec::new();
1249 let mut buf: Vec<u8> = Vec::new();
1250 for l in lines {
1251 buf.extend_from_slice(l.as_bytes());
1252 buf.push(b'\n');
1253 }
1254 drain_sse_lines(&mut buf, &mut acc, &mut deltas).unwrap();
1255 acc
1256 }
1257
1258 #[test]
1259 fn streaming_assembles_tool_calls_and_usage_across_deltas() {
1260 let acc = drain(&[
1261 r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_"}}]}}]}"#,
1262 r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"file","arguments":"{\"path\":"}}]}}]}"#,
1263 r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a\"}"}}]}}]}"#,
1264 r#"data: {"choices":[{"delta":{"content":"done"}}]}"#,
1265 r#"data: {"usage":{"prompt_tokens":3,"completion_tokens":5}}"#,
1266 "data: [DONE]",
1267 ]);
1268 let msg = acc.to_message();
1269 let calls = msg.tool_calls.expect("tool calls");
1270 assert_eq!(calls.len(), 1);
1271 assert_eq!(calls[0].id, "call_1");
1272 assert_eq!(
1273 calls[0].function.name, "read_file",
1274 "name spread over deltas"
1275 );
1276 assert_eq!(calls[0].function.arguments, r#"{"path":"a"}"#);
1277 assert_eq!(msg.content.as_deref(), Some("done"));
1278 assert_eq!(acc.usage.completion_tokens, 5);
1279 }
1280
1281 #[test]
1282 fn streaming_tolerates_done_keepalive_and_blank_lines() {
1283 let acc = drain(&[
1285 "",
1286 ": keep-alive",
1287 r#"data: {"choices":[{"delta":{"content":"hi"}}]}"#,
1288 "data: not-json",
1289 "data: [DONE]",
1290 ]);
1291 assert_eq!(acc.to_message().content.as_deref(), Some("hi"));
1292 }
1293
1294 #[tokio::test]
1295 async fn non_success_status_becomes_provider_error() {
1296 use crate::RuntimeError as Error;
1297 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1298
1299 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1300 let addr = listener.local_addr().unwrap();
1301 let server = tokio::spawn(async move {
1302 let (mut sock, _) = listener.accept().await.unwrap();
1303 let mut buf = [0u8; 2048];
1304 let _ = sock.read(&mut buf).await;
1305 let body = r#"{"error":{"message":"bad key"}}"#;
1306 let resp = format!(
1307 "HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",
1308 body.len(),
1309 body
1310 );
1311 sock.write_all(resp.as_bytes()).await.unwrap();
1312 sock.flush().await.unwrap();
1313 });
1314
1315 let provider = OpenAiProvider::new(format!("http://{addr}"), "k", HashMap::new());
1316 let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1317 let err = provider.complete(&req, &|_: &str| {}).await.unwrap_err();
1318 match err {
1319 Error::Provider { status, body } => {
1320 assert_eq!(status, 401);
1321 assert!(body.contains("bad key"), "body: {body}");
1322 }
1323 other => panic!("expected Provider error, got: {other:?}"),
1324 }
1325 server.await.unwrap();
1326 }
1327
1328 #[tokio::test]
1329 async fn streams_a_200_response_into_a_message() {
1330 use std::sync::{Arc, Mutex};
1331 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1332
1333 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1334 let addr = listener.local_addr().unwrap();
1335 let server = tokio::spawn(async move {
1336 let (mut sock, _) = listener.accept().await.unwrap();
1337 let mut buf = [0u8; 2048];
1338 let _ = sock.read(&mut buf).await;
1339 let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n\
1340 data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n\
1341 data: [DONE]\n\n";
1342 let resp = format!(
1343 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1344 sse.len(),
1345 sse
1346 );
1347 sock.write_all(resp.as_bytes()).await.unwrap();
1348 sock.flush().await.unwrap();
1349 });
1350
1351 let provider = OpenAiProvider::new(format!("http://{addr}"), "k", HashMap::new());
1352 let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1353 let seen = Arc::new(Mutex::new(String::new()));
1354 let seen2 = seen.clone();
1355 let on_delta = move |s: &str| seen2.lock().unwrap().push_str(s);
1356 let (msg, _usage) = provider.complete(&req, &on_delta).await.unwrap();
1357 assert_eq!(msg.content.as_deref(), Some("hello"));
1358 assert_eq!(*seen.lock().unwrap(), "hello", "deltas streamed live");
1359 server.await.unwrap();
1360 }
1361
1362 #[test]
1365 fn from_retry_config_unset_is_byte_identical_to_default() {
1366 let opts = HttpOptions::from_retry_config(true, None, None);
1367 let default = HttpOptions::default();
1368 assert_eq!(opts.max_retries, default.max_retries);
1369 assert_eq!(opts.retry_backoff_base, default.retry_backoff_base);
1370 assert_eq!(opts.connect_timeout, default.connect_timeout);
1371 assert_eq!(opts.read_idle_timeout, default.read_idle_timeout);
1372 }
1373
1374 #[test]
1375 fn from_retry_config_disabled_forces_zero_retries() {
1376 let opts = HttpOptions::from_retry_config(false, None, None);
1377 assert_eq!(opts.max_retries, 0);
1378 assert_eq!(
1381 opts.retry_backoff_base,
1382 HttpOptions::default().retry_backoff_base
1383 );
1384 }
1385
1386 #[test]
1387 fn from_retry_config_disabled_with_explicit_max_retries_still_forces_zero() {
1388 let opts = HttpOptions::from_retry_config(false, Some(5), None);
1391 assert_eq!(opts.max_retries, 0);
1392 }
1393
1394 #[test]
1395 fn from_retry_config_overrides_apply_when_enabled() {
1396 let opts = HttpOptions::from_retry_config(true, Some(7), Some(1234));
1397 assert_eq!(opts.max_retries, 7);
1398 assert_eq!(opts.retry_backoff_base, Duration::from_millis(1234));
1399 }
1400
1401 #[test]
1402 fn from_retry_config_partial_override_leaves_the_other_at_default() {
1403 let opts = HttpOptions::from_retry_config(true, Some(9), None);
1404 assert_eq!(opts.max_retries, 9);
1405 assert_eq!(
1406 opts.retry_backoff_base,
1407 HttpOptions::default().retry_backoff_base
1408 );
1409 }
1410
1411 fn test_http_options() -> HttpOptions {
1414 HttpOptions {
1415 connect_timeout: Duration::from_millis(250),
1416 read_idle_timeout: Duration::from_millis(250),
1417 max_retries: 2,
1418 retry_backoff_base: Duration::from_millis(10),
1419 }
1420 }
1421
1422 #[tokio::test]
1423 async fn hung_connection_errors_via_read_timeout_within_bounded_time() {
1424 use tokio::io::AsyncReadExt;
1425
1426 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1427 let addr = listener.local_addr().unwrap();
1428 let server = tokio::spawn(async move {
1433 loop {
1434 let Ok((mut sock, _)) = listener.accept().await else {
1435 break;
1436 };
1437 tokio::spawn(async move {
1438 let mut buf = [0u8; 2048];
1439 let _ = sock.read(&mut buf).await;
1440 tokio::time::sleep(Duration::from_secs(2)).await;
1443 });
1444 }
1445 });
1446
1447 let provider = OpenAiProvider::new_with_options(
1448 format!("http://{addr}"),
1449 "k",
1450 HashMap::new(),
1451 test_http_options(),
1452 );
1453 let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1454
1455 let outcome = tokio::time::timeout(Duration::from_secs(5), async {
1462 provider.complete(&req, &|_: &str| {}).await
1463 })
1464 .await
1465 .expect("complete() must return within the outer bound, not hang forever");
1466
1467 match outcome {
1468 Err(Error::Http(_)) => {}
1469 other => panic!("expected Err(Error::Http(_)) from the read timeout, got: {other:?}"),
1470 }
1471
1472 server.abort();
1473 }
1474
1475 #[tokio::test]
1476 async fn retries_503_then_succeeds_with_exactly_two_requests() {
1477 use std::sync::atomic::{AtomicUsize, Ordering};
1478 use std::sync::Arc;
1479 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1480
1481 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1482 let addr = listener.local_addr().unwrap();
1483 let connections = Arc::new(AtomicUsize::new(0));
1484 let connections2 = connections.clone();
1485 let server = tokio::spawn(async move {
1486 for _ in 0..2 {
1487 let (mut sock, _) = listener.accept().await.unwrap();
1488 let n = connections2.fetch_add(1, Ordering::SeqCst) + 1;
1489 let mut buf = [0u8; 2048];
1490 let _ = sock.read(&mut buf).await;
1491 if n == 1 {
1492 let resp =
1493 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
1494 sock.write_all(resp.as_bytes()).await.unwrap();
1495 } else {
1496 let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n\
1497 data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n\
1498 data: [DONE]\n\n";
1499 let resp = format!(
1500 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1501 sse.len(),
1502 sse
1503 );
1504 sock.write_all(resp.as_bytes()).await.unwrap();
1505 }
1506 sock.flush().await.unwrap();
1507 }
1508 });
1509
1510 let provider = OpenAiProvider::new_with_options(
1511 format!("http://{addr}"),
1512 "k",
1513 HashMap::new(),
1514 test_http_options(),
1515 );
1516 let req = ChatRequest::new("m", vec![ChatMessage::user("hi")]);
1517 let (msg, _usage) = provider.complete(&req, &|_: &str| {}).await.unwrap();
1518 assert_eq!(msg.content.as_deref(), Some("hello"));
1519 server.await.unwrap();
1520 assert_eq!(
1521 connections.load(Ordering::SeqCst),
1522 2,
1523 "exactly 2 requests made: one 503, one successful retry"
1524 );
1525 }
1526
1527 #[test]
1528 fn streaming_decodes_multibyte_across_chunk_boundaries() {
1529 let line = "data: {\"choices\":[{\"delta\":{\"content\":\"héllo🌍\"}}]}\n";
1532 let bytes = line.as_bytes();
1533 let mut deltas = Vec::new();
1534 for split in 1..bytes.len() {
1536 let mut acc = Accumulator::default();
1537 let mut buf: Vec<u8> = Vec::new();
1538 buf.extend_from_slice(&bytes[..split]);
1539 drain_sse_lines(&mut buf, &mut acc, &mut deltas).unwrap();
1540 buf.extend_from_slice(&bytes[split..]);
1541 drain_sse_lines(&mut buf, &mut acc, &mut deltas).unwrap();
1542 assert_eq!(acc.content, "héllo🌍", "split at byte {split}");
1543 assert!(!acc.content.contains('\u{FFFD}'));
1544 }
1545 }
1546}