1use crate::config::ProviderConfig;
31use crate::message::*;
32use crate::provider::{Provider, StreamEvent, StreamSink};
33use anyhow::{Context, Result};
34use async_trait::async_trait;
35use futures::StreamExt;
36use serde_json::{json, Value};
37use std::collections::BTreeMap;
38
39pub struct OpenAiCompatible {
40 http: reqwest::Client,
41 api_key: Option<String>,
42 base_url: String,
43 default_model: String,
44 temperature: Option<f64>,
45 seed: Option<u64>,
46 id: String,
47 retry: crate::provider::retry::RetryPolicy,
48}
49
50impl OpenAiCompatible {
51 pub fn from_config(cfg: &ProviderConfig) -> Result<Self> {
52 Ok(Self {
53 http: reqwest::Client::builder()
54 .timeout(std::time::Duration::from_secs(900))
55 .build()?,
56 api_key: cfg.resolve_api_key(),
58 base_url: cfg
59 .base_url
60 .clone()
61 .unwrap_or_else(|| "https://api.openai.com".to_string()),
62 default_model: cfg
63 .model
64 .clone()
65 .unwrap_or_else(|| "gpt-4o-mini".to_string()),
66 temperature: cfg.temperature,
67 seed: cfg.seed,
68 id: cfg.kind.clone(),
69 retry: crate::provider::retry::RetryPolicy::from_config(cfg),
70 })
71 }
72
73 fn body(&self, req: &CompletionRequest, stream: bool) -> Value {
74 let mut messages = Vec::new();
75 if let Some(system) = &req.system {
76 messages.push(json!({"role": "system", "content": system}));
77 }
78 for m in &req.messages {
79 encode_message(m, &mut messages);
80 }
81
82 let mut body = json!({
83 "model": req.model,
84 "max_tokens": req.max_tokens,
85 "messages": messages,
86 });
87 let obj = body.as_object_mut().unwrap();
88 if let Some(t) = self.temperature {
89 obj.insert("temperature".into(), json!(t));
90 }
91 if let Some(s) = self.seed {
92 obj.insert("seed".into(), json!(s));
93 }
94 if stream {
95 obj.insert("stream".into(), json!(true));
96 obj.insert("stream_options".into(), json!({"include_usage": true}));
97 }
98 if !req.tools.is_empty() {
99 let tools: Vec<Value> = req
100 .tools
101 .iter()
102 .map(|t| {
103 json!({"type": "function", "function": {
104 "name": t.name,
105 "description": t.description,
106 "parameters": t.input_schema,
107 }})
108 })
109 .collect();
110 obj.insert("tools".into(), json!(tools));
111 }
112 body
113 }
114
115 fn request(&self, body: &Value) -> reqwest::RequestBuilder {
116 let mut rb = self
117 .http
118 .post(format!(
119 "{}/v1/chat/completions",
120 self.base_url.trim_end_matches('/')
121 ))
122 .header("content-type", "application/json");
123 if let Some(key) = &self.api_key {
124 rb = rb.bearer_auth(key);
125 }
126 rb.json(body)
127 }
128}
129
130#[async_trait]
131impl Provider for OpenAiCompatible {
132 fn id(&self) -> &str {
133 &self.id
134 }
135
136 fn default_model(&self) -> &str {
137 &self.default_model
138 }
139
140 async fn complete(
141 &self,
142 req: &CompletionRequest,
143 sink: Option<&StreamSink>,
144 ) -> Result<CompletionResponse> {
145 let body = self.body(req, sink.is_some());
146 let resp = crate::provider::retry::send_with_retry(|| self.request(&body), &self.retry)
151 .await
152 .map_err(|f| {
153 let message = match f.status {
154 Some(status) => format!(
155 "{} {status}: {}",
156 self.id,
157 f.detail.chars().take(500).collect::<String>()
158 ),
159 None => format!("{}: {}", self.id, f.detail),
160 };
161 anyhow::Error::new(f.class).context(message)
162 })?;
163
164 let Some(sink) = sink else {
165 let v: Value = resp.json().await.context("malformed response body")?;
166 return decode_response(&v);
167 };
168
169 let mut acc = Accumulator::default();
170 let mut buf = crate::provider::sse::SseBuffer::default();
171 let mut stream = resp.bytes_stream();
172 while let Some(chunk) = stream.next().await {
173 buf.push(&chunk?);
174 while let Some(line) = buf.next_segment(b"\n") {
178 let Some(data) = line.trim().strip_prefix("data:") else {
179 continue;
180 };
181 let data = data.trim();
182 if data.is_empty() || data == "[DONE]" {
183 continue;
184 }
185 let v: Value = serde_json::from_str(data).context("malformed SSE data frame")?;
186 acc.push(&v, sink);
187 }
188 }
189 Ok(acc.finish())
190 }
191}
192
193fn encode_message(m: &Message, out: &mut Vec<Value>) {
197 match m.role {
198 Role::Assistant => {
199 let mut text = String::new();
200 let mut reasoning = String::new();
201 let mut tool_calls = Vec::new();
202 for b in &m.content {
203 match b {
204 Block::Text { text: t } => text.push_str(t),
205 Block::Thinking { text: t, .. } => reasoning.push_str(t),
230 Block::ToolUse { id, name, input } => tool_calls.push(json!({
231 "id": id,
232 "type": "function",
233 "function": {"name": name, "arguments": input.to_string()},
234 })),
235 Block::ToolResult { .. } => {}
237 }
238 }
239 let mut msg = json!({"role": "assistant"});
240 let obj = msg.as_object_mut().unwrap();
241 obj.insert(
242 "content".into(),
243 if text.is_empty() {
244 Value::Null
245 } else {
246 json!(text)
247 },
248 );
249 if !tool_calls.is_empty() {
250 obj.insert("tool_calls".into(), json!(tool_calls));
251 }
252 if !reasoning.is_empty() {
256 obj.insert("reasoning_content".into(), json!(reasoning));
257 }
258 out.push(msg);
259 }
260 Role::User => {
261 let mut text = String::new();
262 for b in &m.content {
263 match b {
264 Block::Text { text: t } => text.push_str(t),
265 Block::ToolResult {
266 tool_use_id,
267 content,
268 ..
269 } => out.push(json!({
270 "role": "tool",
271 "tool_call_id": tool_use_id,
272 "content": content,
273 })),
274 _ => {}
275 }
276 }
277 if !text.is_empty() {
278 out.push(json!({"role": "user", "content": text}));
279 }
280 }
281 }
282}
283
284fn decode_finish(s: Option<&str>) -> StopReason {
285 match s {
286 Some("stop") => StopReason::EndTurn,
287 Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
288 Some("length") => StopReason::MaxTokens,
289 Some("content_filter") => StopReason::Refusal,
290 _ => StopReason::Other,
291 }
292}
293
294fn decode_usage(v: Option<&Value>) -> Usage {
295 let Some(v) = v else { return Usage::default() };
296 let g = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
297 let prompt = g("prompt_tokens");
298
299 let cached = v
309 .pointer("/prompt_tokens_details/cached_tokens")
310 .and_then(Value::as_u64)
311 .unwrap_or(0)
312 .min(prompt);
313
314 Usage {
317 input_tokens: prompt - cached,
318 output_tokens: g("completion_tokens"),
319 cache_read_input_tokens: cached,
320 ..Usage::default()
321 }
322}
323
324fn produced_output(blocks: &[Block]) -> bool {
333 blocks.iter().any(|b| match b {
334 Block::Text { text } => !text.trim().is_empty(),
335 Block::ToolUse { .. } => true,
336 Block::Thinking { .. } | Block::ToolResult { .. } => false,
337 })
338}
339
340const TOOL_CALL_MARKERS: &[&str] = &[
385 "<tool_call>", "<function=", "<|python_tag|>", "<|tool▁call▁begin|>", "```tool_code", "<function_call>", ];
392
393#[derive(Debug, PartialEq)]
394struct DroppedReasoning<'a> {
395 chars: usize,
396 looks_like_tool_call: bool,
399 tail: &'a str,
402}
403
404fn dropped_reasoning(produced_output: bool, reasoning: &str) -> Option<DroppedReasoning<'_>> {
407 if produced_output || reasoning.trim().is_empty() {
408 return None;
409 }
410 let tail = match reasoning.char_indices().rev().nth(400) {
413 Some((i, _)) => &reasoning[i..],
414 None => reasoning,
415 };
416 Some(DroppedReasoning {
417 chars: reasoning.chars().count(),
418 looks_like_tool_call: TOOL_CALL_MARKERS.iter().any(|m| reasoning.contains(m)),
419 tail,
420 })
421}
422
423fn log_dropped_reasoning(produced_output: bool, reasoning: &str, finish: Option<&str>) {
424 if let Some(d) = dropped_reasoning(produced_output, reasoning) {
425 tracing::warn!(
426 reasoning_chars = d.chars,
427 looks_like_tool_call = d.looks_like_tool_call,
428 finish_reason = finish.unwrap_or("<absent>"),
429 tail = d.tail,
430 "turn produced no output but the response carried reasoning_content"
431 );
432 tracing::debug!(reasoning = reasoning, "the dropped reasoning, in full");
440 }
441}
442
443fn parse_arguments(name: &str, raw: &str) -> Result<Value> {
444 if raw.trim().is_empty() {
445 return Ok(json!({}));
446 }
447 serde_json::from_str(raw)
448 .with_context(|| format!("tool {name} returned unparseable arguments: {raw}"))
449}
450
451fn decode_response(v: &Value) -> Result<CompletionResponse> {
452 let mut malformed = 0u32;
453 let choice = v.pointer("/choices/0").context("response has no choices")?;
454 let msg = choice.get("message").context("choice has no message")?;
455
456 let mut content = Vec::new();
457 let reasoning = msg
463 .get("reasoning_content")
464 .and_then(Value::as_str)
465 .unwrap_or("");
466 if !reasoning.is_empty() {
467 content.push(Block::Thinking {
468 text: reasoning.to_string(),
469 signature: None,
470 });
471 }
472 if let Some(text) = msg.get("content").and_then(Value::as_str) {
473 if !text.is_empty() {
474 content.push(Block::text(text));
475 }
476 }
477 for call in msg
478 .get("tool_calls")
479 .and_then(Value::as_array)
480 .unwrap_or(&vec![])
481 {
482 let name = call
483 .pointer("/function/name")
484 .and_then(Value::as_str)
485 .unwrap_or_default()
486 .to_string();
487 let raw = call
488 .pointer("/function/arguments")
489 .and_then(Value::as_str)
490 .unwrap_or("");
491 let input = match parse_arguments(&name, raw) {
492 Ok(v) => v,
493 Err(_) => {
494 malformed += 1;
495 json!({"__malformed_arguments": raw})
496 }
497 };
498 content.push(Block::ToolUse {
499 id: call
500 .get("id")
501 .and_then(Value::as_str)
502 .unwrap_or_default()
503 .to_string(),
504 input,
505 name,
506 });
507 }
508
509 let finish = choice.get("finish_reason").and_then(Value::as_str);
510 log_dropped_reasoning(produced_output(&content), reasoning, finish);
511
512 Ok(CompletionResponse {
513 message: Message::assistant(content),
514 stop_reason: decode_finish(finish),
515 usage: decode_usage(v.get("usage")),
516 refusal: None,
517 model: v
518 .get("model")
519 .and_then(Value::as_str)
520 .unwrap_or_default()
521 .to_string(),
522 malformed_tool_args: malformed,
523 })
524}
525
526#[derive(Default)]
527struct Accumulator {
528 text: String,
529 calls: BTreeMap<u64, (String, String, String)>,
531 finish: Option<StopReason>,
532 usage: Usage,
533 model: String,
534 reasoning: String,
538}
539
540impl Accumulator {
541 fn push(&mut self, v: &Value, sink: &StreamSink) {
542 if let Some(m) = v.get("model").and_then(Value::as_str) {
543 self.model = m.to_string();
544 }
545 if let Some(u) = v.get("usage") {
546 if !u.is_null() {
547 self.usage = decode_usage(Some(u));
548 let _ = sink.send(StreamEvent::Usage(self.usage.clone()));
554 }
555 }
556 let Some(choice) = v.pointer("/choices/0") else {
557 return;
558 };
559 if let Some(f) = choice.get("finish_reason").and_then(Value::as_str) {
560 self.finish = Some(decode_finish(Some(f)));
561 }
562 let Some(delta) = choice.get("delta") else {
563 return;
564 };
565
566 if let Some(t) = delta.get("content").and_then(Value::as_str) {
567 self.text.push_str(t);
568 let _ = sink.send(StreamEvent::TextDelta(t.to_string()));
569 }
570 if let Some(r) = delta.get("reasoning_content").and_then(Value::as_str) {
575 self.reasoning.push_str(r);
576 let _ = sink.send(StreamEvent::ThinkingDelta(r.to_string()));
577 }
578 for call in delta
579 .get("tool_calls")
580 .and_then(Value::as_array)
581 .unwrap_or(&vec![])
582 {
583 let idx = call.get("index").and_then(Value::as_u64).unwrap_or(0);
584 let entry = self.calls.entry(idx).or_default();
585 if let Some(id) = call.get("id").and_then(Value::as_str) {
586 entry.0 = id.to_string();
587 }
588 if let Some(name) = call.pointer("/function/name").and_then(Value::as_str) {
589 if entry.1.is_empty() && !name.is_empty() {
590 let _ = sink.send(StreamEvent::ToolUseStart {
591 name: name.to_string(),
592 });
593 }
594 entry.1.push_str(name);
595 }
596 if let Some(args) = call.pointer("/function/arguments").and_then(Value::as_str) {
597 entry.2.push_str(args);
598 }
599 }
600 }
601
602 fn finish(self) -> CompletionResponse {
603 let mut content = Vec::new();
604 let mut malformed = 0u32;
605 if !self.reasoning.is_empty() {
606 content.push(Block::Thinking {
607 text: self.reasoning.clone(),
608 signature: None,
609 });
610 }
611 if !self.text.is_empty() {
612 content.push(Block::text(self.text));
613 }
614 for (_, (id, name, args)) in self.calls {
615 let input = match parse_arguments(&name, &args) {
618 Ok(v) => v,
619 Err(_) => {
620 malformed += 1;
621 json!({"__malformed_arguments": args})
622 }
623 };
624 content.push(Block::ToolUse { id, name, input });
625 }
626 log_dropped_reasoning(produced_output(&content), &self.reasoning, None);
627 CompletionResponse {
628 message: Message::assistant(content),
629 stop_reason: self.finish.unwrap_or(StopReason::Other),
630 usage: self.usage,
631 refusal: None,
632 model: self.model,
633 malformed_tool_args: malformed,
634 }
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 fn provider(temperature: Option<f64>, seed: Option<u64>) -> OpenAiCompatible {
643 OpenAiCompatible::from_config(&ProviderConfig {
644 kind: "local".into(),
645 temperature,
646 seed,
647 ..Default::default()
648 })
649 .unwrap()
650 }
651
652 fn plain_req() -> CompletionRequest {
653 CompletionRequest {
654 model: "m".into(),
655 system: None,
656 messages: vec![Message::user("hi")],
657 tools: Vec::new(),
658 max_tokens: 64,
659 effort: None,
660 thinking: false,
661 cache_prompt: false,
662 }
663 }
664
665 #[test]
666 fn a_pinned_sampler_is_sent_and_an_unpinned_one_is_absent() {
667 let body = provider(Some(0.8), Some(42)).body(&plain_req(), false);
668 assert_eq!(body["temperature"], json!(0.8));
669 assert_eq!(body["seed"], json!(42));
670
671 let body = provider(None, None).body(&plain_req(), false);
674 assert!(body.get("temperature").is_none());
675 assert!(body.get("seed").is_none());
676 }
677
678 fn sink() -> (
679 StreamSink,
680 tokio::sync::mpsc::UnboundedReceiver<StreamEvent>,
681 ) {
682 tokio::sync::mpsc::unbounded_channel()
683 }
684
685 fn chunk(delta: Value) -> Value {
687 json!({"choices": [{"index": 0, "delta": delta}]})
688 }
689
690 #[test]
691 fn a_turn_that_produced_output_reports_no_dropped_reasoning() {
692 assert_eq!(dropped_reasoning(true, "a long think"), None);
696 }
697
698 #[test]
699 fn thinking_is_not_output_so_a_reasoning_only_turn_still_reports() {
700 let blocks = vec![Block::Thinking {
706 text: "thinking".into(),
707 signature: None,
708 }];
709 assert!(!produced_output(&blocks));
710 assert!(dropped_reasoning(produced_output(&blocks), "thinking").is_some());
711 }
712
713 #[test]
714 fn whitespace_only_text_is_not_output_either() {
715 assert!(!produced_output(&[Block::text(" \n ")]));
718 assert!(produced_output(&[Block::text("an answer")]));
719 assert!(produced_output(&[Block::ToolUse {
720 id: "t1".into(),
721 name: "shell".into(),
722 input: json!({}),
723 }]));
724 }
725
726 #[test]
727 fn an_empty_turn_with_an_empty_reasoning_channel_reports_nothing() {
728 assert_eq!(dropped_reasoning(false, ""), None);
732 assert_eq!(dropped_reasoning(false, " \n "), None);
733 }
734
735 #[test]
736 fn an_empty_turn_carrying_a_tool_call_in_its_reasoning_is_named_as_one() {
737 let d = dropped_reasoning(
740 false,
741 "let me check the file\n<tool_call>\n{\"name\": \"shell\"}",
742 )
743 .expect("an empty turn with reasoning is reportable");
744 assert!(
745 d.looks_like_tool_call,
746 "a <tool_call> in the think block is the lost-call signature"
747 );
748 }
749
750 #[test]
751 fn reasoning_survives_the_round_trip_and_rides_back_with_the_turn() {
752 let v = json!({
758 "choices": [{
759 "finish_reason": "tool_calls",
760 "message": {
761 "role": "assistant",
762 "content": null,
763 "reasoning_content": "I should list the directory first.",
764 "tool_calls": [{
765 "id": "call_1",
766 "type": "function",
767 "function": {"name": "shell", "arguments": "{\"command\": \"ls\"}"},
768 }],
769 },
770 }],
771 "model": "qwen3.6-35b-a3b",
772 });
773 let decoded = decode_response(&v).unwrap();
774
775 let mut out = Vec::new();
776 encode_message(&decoded.message, &mut out);
777 assert_eq!(out.len(), 1);
778 assert_eq!(
779 out[0]["reasoning_content"], "I should list the directory first.",
780 "the reasoning was dropped on the way back out"
781 );
782 assert_eq!(out[0]["tool_calls"][0]["function"]["name"], "shell");
783 assert_eq!(
784 out[0]["content"],
785 Value::Null,
786 "reasoning must not leak into content"
787 );
788 }
789
790 #[test]
791 fn cached_prompt_tokens_are_split_out_rather_than_counted_twice() {
792 let u = decode_usage(Some(&json!({
797 "prompt_tokens": 1000,
798 "completion_tokens": 42,
799 "prompt_tokens_details": {"cached_tokens": 800},
800 })));
801 assert_eq!(u.input_tokens, 200, "the uncached remainder");
802 assert_eq!(u.cache_read_input_tokens, 800);
803 assert_eq!(u.output_tokens, 42);
804 assert_eq!(
805 u.total_input(),
806 1000,
807 "the reported prompt size must survive the split unchanged"
808 );
809 }
810
811 #[test]
812 fn a_server_that_reports_no_cache_detail_is_unchanged() {
813 let u = decode_usage(Some(&json!({"prompt_tokens": 500, "completion_tokens": 7})));
816 assert_eq!(u.input_tokens, 500);
817 assert_eq!(u.cache_read_input_tokens, 0);
818 assert_eq!(u.total_input(), 500);
819 }
820
821 #[test]
822 fn a_cached_count_larger_than_the_prompt_cannot_underflow() {
823 let u = decode_usage(Some(&json!({
827 "prompt_tokens": 100,
828 "completion_tokens": 1,
829 "prompt_tokens_details": {"cached_tokens": 9999},
830 })));
831 assert_eq!(u.input_tokens, 0);
832 assert_eq!(u.cache_read_input_tokens, 100);
833 assert_eq!(u.total_input(), 100);
834 }
835
836 #[test]
837 fn a_turn_with_no_thinking_sends_no_reasoning_field() {
838 let mut out = Vec::new();
843 encode_message(&Message::assistant(vec![Block::text("done")]), &mut out);
844 assert!(
845 out[0].get("reasoning_content").is_none(),
846 "an unrelated endpoint must not be sent a field it never spoke"
847 );
848 }
849
850 #[test]
851 fn the_lost_call_signature_is_not_only_qwens() {
852 for (family, reasoning) in [
859 (
860 "gemma",
861 "let me check\n```tool_code\nprint(shell(...))\n```",
862 ),
863 (
864 "llama",
865 "first I will look\n<|python_tag|>{\"name\": \"shell\"}",
866 ),
867 ("deepseek", "checking\n<|tool▁call▁begin|>shell"),
868 ("hermes", "<function_call>{\"name\": \"shell\"}"),
869 ] {
870 let d = dropped_reasoning(false, reasoning)
871 .unwrap_or_else(|| panic!("{family}: reportable"));
872 assert!(d.looks_like_tool_call, "{family} went unrecognised");
873 }
874 }
875
876 #[test]
877 fn reasoning_without_a_call_is_reported_but_not_labelled_a_call() {
878 let d = dropped_reasoning(false, "I think the answer is 42, so I am done.")
879 .expect("an empty turn with reasoning is reportable");
880 assert!(!d.looks_like_tool_call);
881 assert_eq!(d.chars, 39);
882 }
883
884 #[test]
885 fn the_tail_is_kept_and_multibyte_reasoning_does_not_panic() {
886 let long = format!("{}—the answer is 42", "x".repeat(5_000));
891 let d = dropped_reasoning(false, &long).expect("reportable");
892 assert_eq!(d.chars, 5_017);
893 assert!(d.tail.ends_with("—the answer is 42"));
894 assert!(
895 d.tail.chars().count() <= 401,
896 "the tail is bounded, not the whole think block"
897 );
898
899 let d = dropped_reasoning(false, "早い").expect("reportable");
901 assert_eq!(d.tail, "早い");
902 }
903
904 #[test]
905 fn llama_servers_empty_turn_shape_decodes_to_no_blocks_and_is_reported() {
906 let v = json!({
912 "choices": [{
913 "finish_reason": "stop",
914 "message": {
915 "role": "assistant",
916 "content": null,
917 "reasoning_content": "I should read the file first.\n<tool_call>",
918 },
919 }],
920 "model": "qwen3.6-35b-a3b",
921 });
922 let resp = decode_response(&v).unwrap();
923
924 assert_eq!(resp.stop_reason, StopReason::EndTurn);
925
926 assert_eq!(
930 resp.message.content.len(),
931 1,
932 "reasoning_content should survive decoding as a Thinking block"
933 );
934 assert!(matches!(resp.message.content[0], Block::Thinking { .. }));
935
936 assert_eq!(
939 resp.message.text(),
940 "",
941 "reasoning_content must never silently become the answer"
942 );
943 assert!(resp.message.tool_uses().is_empty());
944 assert!(!produced_output(&resp.message.content));
945
946 let d = dropped_reasoning(
948 produced_output(&resp.message.content),
949 "I should read the file first.\n<tool_call>",
950 )
951 .expect("this is the shape the diagnostic exists for");
952 assert!(d.looks_like_tool_call);
953 }
954
955 #[test]
956 fn streamed_reasoning_arrives_as_thinking_and_never_as_answer_text() {
957 let (tx, mut rx) = sink();
962 let mut acc = Accumulator::default();
963 acc.push(&chunk(json!({"reasoning_content": "thinking "})), &tx);
964 acc.push(&chunk(json!({"reasoning_content": "hard"})), &tx);
965 acc.push(
966 &json!({"choices": [{"index": 0, "finish_reason": "stop", "delta": {}}]}),
967 &tx,
968 );
969
970 assert_eq!(
974 acc.reasoning, "thinking hard",
975 "deltas must accumulate, or the diagnostic has nothing to report"
976 );
977
978 let resp = acc.finish();
979 assert_eq!(resp.stop_reason, StopReason::EndTurn);
980 assert_eq!(
981 resp.message.text(),
982 "",
983 "a reasoning-only stream produced no answer"
984 );
985 assert!(
986 !produced_output(&resp.message.content),
987 "and must still be nudged rather than ending the run"
988 );
989
990 rx.close();
992 let mut events = Vec::new();
993 while let Ok(e) = rx.try_recv() {
994 events.push(e);
995 }
996 let thinking: Vec<_> = events
997 .iter()
998 .filter_map(|e| match e {
999 StreamEvent::ThinkingDelta(t) => Some(t.as_str()),
1000 _ => None,
1001 })
1002 .collect();
1003 assert_eq!(thinking, vec!["thinking ", "hard"]);
1004 assert!(
1005 !events
1006 .iter()
1007 .any(|e| matches!(e, StreamEvent::TextDelta(_))),
1008 "reasoning must not be emitted as a TextDelta"
1009 );
1010 }
1011
1012 fn call_delta(index: u64, id: Option<&str>, name: Option<&str>, args: &str) -> Value {
1013 let mut function = serde_json::Map::new();
1014 if let Some(name) = name {
1015 function.insert("name".into(), json!(name));
1016 }
1017 function.insert("arguments".into(), json!(args));
1018
1019 let mut call = serde_json::Map::new();
1020 call.insert("index".into(), json!(index));
1021 if let Some(id) = id {
1022 call.insert("id".into(), json!(id));
1023 }
1024 call.insert("type".into(), json!("function"));
1025 call.insert("function".into(), Value::Object(function));
1026
1027 chunk(json!({"tool_calls": [Value::Object(call)]}))
1028 }
1029
1030 #[test]
1031 fn tool_call_arguments_split_across_chunks_reassemble_into_one_object() {
1032 let (tx, _rx) = sink();
1036 let mut acc = Accumulator::default();
1037
1038 acc.push(&call_delta(0, Some("call_1"), Some("fs_"), ""), &tx);
1039 acc.push(&call_delta(0, None, Some("read"), "{\"pa"), &tx);
1040 acc.push(&call_delta(0, None, None, "th\": \"notes/"), &tx);
1041 acc.push(&call_delta(0, None, None, "a.md\"}"), &tx);
1042
1043 let resp = acc.finish();
1044 let calls = resp.message.tool_uses();
1045
1046 assert_eq!(calls.len(), 1);
1047 assert_eq!(calls[0].0, "call_1");
1048 assert_eq!(calls[0].1, "fs_read");
1049 assert_eq!(calls[0].2, &json!({"path": "notes/a.md"}));
1050 assert_eq!(resp.malformed_tool_args, 0);
1051 }
1052
1053 #[test]
1054 fn parallel_tool_calls_are_kept_apart_by_their_index() {
1055 let (tx, _rx) = sink();
1058 let mut acc = Accumulator::default();
1059
1060 acc.push(
1061 &call_delta(0, Some("call_a"), Some("fs_read"), "{\"path\":"),
1062 &tx,
1063 );
1064 acc.push(
1065 &call_delta(1, Some("call_b"), Some("shell"), "{\"cmd\":"),
1066 &tx,
1067 );
1068 acc.push(&call_delta(0, None, None, " \"a.md\"}"), &tx);
1069 acc.push(&call_delta(1, None, None, " \"ls\"}"), &tx);
1070
1071 let resp = acc.finish();
1072 let calls = resp.message.tool_uses();
1073
1074 assert_eq!(calls.len(), 2);
1075 assert_eq!(calls[0].1, "fs_read");
1076 assert_eq!(calls[0].2, &json!({"path": "a.md"}));
1077 assert_eq!(calls[1].1, "shell");
1078 assert_eq!(calls[1].2, &json!({"cmd": "ls"}));
1079 }
1080
1081 #[test]
1082 fn a_call_with_no_arguments_becomes_an_empty_object_not_a_parse_failure() {
1083 let (tx, _rx) = sink();
1084 let mut acc = Accumulator::default();
1085 acc.push(&call_delta(0, Some("call_1"), Some("todo_read"), ""), &tx);
1086
1087 let resp = acc.finish();
1088 assert_eq!(resp.message.tool_uses()[0].2, &json!({}));
1089 assert_eq!(resp.malformed_tool_args, 0);
1090 }
1091
1092 #[test]
1093 fn malformed_arguments_are_counted_and_handed_back_rather_than_killing_the_turn() {
1094 let (tx, _rx) = sink();
1095 let mut acc = Accumulator::default();
1096 acc.push(
1097 &call_delta(0, Some("call_1"), Some("fs_read"), "{\"path\": "),
1098 &tx,
1099 );
1100
1101 let resp = acc.finish();
1102
1103 assert_eq!(resp.malformed_tool_args, 1);
1106 assert!(resp.message.tool_uses()[0]
1107 .2
1108 .get("__malformed_arguments")
1109 .is_some());
1110 }
1111
1112 #[test]
1113 fn tool_calls_are_still_decoded_when_the_server_says_the_turn_merely_stopped() {
1114 let (tx, _rx) = sink();
1118 let mut acc = Accumulator::default();
1119 acc.push(
1120 &call_delta(0, Some("call_1"), Some("fs_read"), "{\"path\": \"a.md\"}"),
1121 &tx,
1122 );
1123 acc.push(
1124 &json!({"choices": [{"index": 0, "finish_reason": "stop", "delta": {}}]}),
1125 &tx,
1126 );
1127
1128 let resp = acc.finish();
1129 assert_eq!(resp.stop_reason, StopReason::EndTurn);
1130 assert_eq!(
1131 resp.message.tool_uses().len(),
1132 1,
1133 "the calls were dropped with the label"
1134 );
1135
1136 let v = json!({
1138 "choices": [{
1139 "finish_reason": "stop",
1140 "message": {
1141 "content": null,
1142 "tool_calls": [{
1143 "id": "call_1",
1144 "type": "function",
1145 "function": {"name": "fs_read", "arguments": "{\"path\": \"a.md\"}"},
1146 }],
1147 },
1148 }],
1149 "model": "local",
1150 });
1151 let resp = decode_response(&v).unwrap();
1152 assert_eq!(resp.stop_reason, StopReason::EndTurn);
1153 assert_eq!(resp.message.tool_uses().len(), 1);
1154 }
1155
1156 #[test]
1157 fn text_and_tool_calls_in_one_turn_both_survive() {
1158 let (tx, _rx) = sink();
1159 let mut acc = Accumulator::default();
1160 acc.push(&chunk(json!({"content": "let me look. "})), &tx);
1161 acc.push(&call_delta(0, Some("call_1"), Some("fs_read"), "{}"), &tx);
1162 acc.push(&chunk(json!({"content": "one moment."})), &tx);
1163
1164 let resp = acc.finish();
1165 assert_eq!(resp.message.text(), "let me look. one moment.");
1166 assert_eq!(resp.message.tool_uses().len(), 1);
1167 }
1168
1169 #[test]
1170 fn tool_results_become_their_own_messages_and_a_steer_follows_them() {
1171 let mut out = Vec::new();
1176 encode_message(
1177 &Message::tool_results(vec![
1178 Block::ToolResult {
1179 tool_use_id: "t1".into(),
1180 content: "42".into(),
1181 is_error: false,
1182 },
1183 Block::ToolResult {
1184 tool_use_id: "t2".into(),
1185 content: "7".into(),
1186 is_error: false,
1187 },
1188 Block::text("actually, focus on X"),
1189 ]),
1190 &mut out,
1191 );
1192
1193 assert_eq!(out.len(), 3);
1194 assert_eq!(out[0]["role"], "tool");
1195 assert_eq!(out[0]["tool_call_id"], "t1");
1196 assert_eq!(out[1]["role"], "tool");
1197 assert_eq!(out[1]["tool_call_id"], "t2");
1198 assert_eq!(out[2]["role"], "user");
1199 assert_eq!(out[2]["content"], "actually, focus on X");
1200 }
1201
1202 #[test]
1203 fn an_assistant_turn_carries_its_tool_calls_inline_with_arguments_as_a_string() {
1204 let mut out = Vec::new();
1205 encode_message(
1206 &Message::assistant(vec![Block::ToolUse {
1207 id: "call_1".into(),
1208 name: "fs_read".into(),
1209 input: json!({"path": "a.md"}),
1210 }]),
1211 &mut out,
1212 );
1213
1214 assert_eq!(out.len(), 1);
1215 assert_eq!(out[0]["role"], "assistant");
1216 assert_eq!(out[0]["content"], Value::Null);
1217 let args = out[0]["tool_calls"][0]["function"]["arguments"]
1219 .as_str()
1220 .unwrap();
1221 assert_eq!(
1222 serde_json::from_str::<Value>(args).unwrap(),
1223 json!({"path": "a.md"})
1224 );
1225 }
1226}