1use crate::canonical::ChatRequest;
11use crate::error::ProxyError;
12use crate::items::{ContentItem, ItemRequest, ResponseFormat, Role};
13
14#[derive(Default)]
18struct MsgAcc {
19 saw_payload: bool,
20 content: String,
21 reasoning: String,
22 tool_calls: Vec<serde_json::Value>,
23 thinking_blocks: Vec<serde_json::Value>,
24}
25
26impl MsgAcc {
27 fn fold(
29 &mut self,
30 item: &ContentItem,
31 out_tool_msgs: &mut Vec<serde_json::Value>,
32 ) -> Result<(), ProxyError> {
33 match item {
34 ContentItem::Text { text } => {
35 self.saw_payload = true;
36 self.content.push_str(text);
37 }
38 ContentItem::Thinking {
39 text,
40 signature,
41 redacted_data,
42 ..
43 } => {
44 self.saw_payload = true;
45 if let Some(data) = redacted_data {
46 self.thinking_blocks.push(serde_json::json!({
47 "type": "redacted_thinking",
48 "data": data,
49 }));
50 } else if signature.is_some() {
51 self.thinking_blocks.push(serde_json::json!({
52 "type": "thinking",
53 "thinking": text,
54 "signature": signature,
55 }));
56 } else {
57 self.reasoning.push_str(text);
58 }
59 }
60 ContentItem::ToolCall {
61 id,
62 name,
63 arguments,
64 } => {
65 self.saw_payload = true;
66 self.tool_calls.push(serde_json::json!({
67 "id": id,
68 "type": "function",
69 "function": {
70 "name": name,
71 "arguments": arguments.to_string(),
72 },
73 }));
74 }
75 ContentItem::ToolResult {
76 tool_call_id: tid,
77 content: c,
78 is_error,
79 } => {
80 let mut out = match c {
84 serde_json::Value::String(s) => s.clone(),
85 serde_json::Value::Array(parts) => parts
86 .iter()
87 .filter_map(|p| p["text"].as_str())
88 .collect::<Vec<_>>()
89 .join("\n"),
90 other => {
91 return Err(ProxyError::BadRequest(format!(
92 "tool result content must be text or text parts, got {}",
93 other
94 .as_object()
95 .map(|_| "object")
96 .unwrap_or("non-text value")
97 )));
98 }
99 };
100 if *is_error {
101 out = format!("[tool error] {out}");
102 }
103 out_tool_msgs.push(serde_json::json!({
104 "role": "tool",
105 "tool_call_id": tid,
106 "content": out,
107 }));
108 }
109 ContentItem::Refusal { text } => {
110 self.saw_payload = true;
111 self.content.push_str(text);
112 }
113 }
114 Ok(())
115 }
116
117 fn finish(self, role: &str, name: Option<&str>) -> Option<serde_json::Value> {
119 if !self.saw_payload {
120 return None;
121 }
122 let MsgAcc {
123 saw_payload: _,
124 mut content,
125 mut reasoning,
126 mut tool_calls,
127 mut thinking_blocks,
128 } = self;
129 let mut msg = serde_json::json!({"role": role});
130 if let Some(n) = name {
131 msg["name"] = serde_json::json!(n);
132 }
133 match role {
134 "assistant" => {
135 if !content.is_empty() || tool_calls.is_empty() {
139 msg["content"] = serde_json::Value::String(std::mem::take(&mut content));
140 }
141 if !reasoning.is_empty() {
142 msg["reasoning_content"] =
143 serde_json::Value::String(std::mem::take(&mut reasoning));
144 }
145 if !tool_calls.is_empty() {
146 msg["tool_calls"] = serde_json::Value::Array(std::mem::take(&mut tool_calls));
147 }
148 if !thinking_blocks.is_empty() {
149 msg["thinking_blocks"] =
150 serde_json::Value::Array(std::mem::take(&mut thinking_blocks));
151 }
152 }
153 _ => {
154 msg["content"] = serde_json::Value::String(std::mem::take(&mut content));
155 }
156 }
157 Some(msg)
158 }
159}
160
161fn tools_to_wire(items: &ItemRequest) -> Option<serde_json::Value> {
163 if items.tools.is_empty() {
164 return None;
165 }
166 Some(serde_json::Value::Array(
167 items
168 .tools
169 .iter()
170 .map(|t| {
171 serde_json::json!({
172 "type": "function",
173 "function": {
174 "name": t.name,
175 "description": t.description,
176 "parameters": t.input_schema,
177 },
178 })
179 })
180 .collect(),
181 ))
182}
183
184fn tool_choice_to_wire(tc: &crate::items::ToolChoice) -> serde_json::Value {
185 match tc {
186 crate::items::ToolChoice::Auto => serde_json::json!("auto"),
187 crate::items::ToolChoice::None => serde_json::json!("none"),
188 crate::items::ToolChoice::Required => serde_json::json!("required"),
189 crate::items::ToolChoice::Tool { name } => serde_json::json!({
190 "type": "function",
191 "function": {"name": name},
192 }),
193 }
194}
195
196fn response_format_to_wire(fmt: &ResponseFormat) -> serde_json::Value {
197 match fmt {
198 ResponseFormat::Text => serde_json::json!({"type":"text"}),
199 ResponseFormat::JsonObject => serde_json::json!({"type":"json_object"}),
200 ResponseFormat::JsonSchema {
201 name,
202 schema,
203 strict,
204 } => serde_json::json!({
205 "type": "json_schema",
206 "json_schema": { "name": name, "schema": schema, "strict": strict },
207 }),
208 }
209}
210
211pub fn items_to_chat_request(items: &ItemRequest) -> Result<ChatRequest, ProxyError> {
216 let mut messages: Vec<serde_json::Value> = Vec::new();
217 for m in &items.messages {
218 let role = match m.role {
219 Role::System => "system",
220 Role::User => "user",
221 Role::Assistant => "assistant",
222 Role::Tool => "user",
226 };
227 let mut acc = MsgAcc::default();
228 for item in &m.items {
229 acc.fold(item, &mut messages)?;
230 }
231 if let Some(msg) = acc.finish(role, m.metadata.name.as_deref()) {
232 messages.push(msg);
233 }
234 }
235 let mut v = serde_json::json!({
236 "model": items.model,
237 "messages": messages,
238 "stream": items.stream,
239 });
240 if let Some(mt) = items.max_tokens {
241 v["max_tokens"] = serde_json::json!(mt);
242 }
243 if let Some(t) = items.temperature {
244 v["temperature"] = serde_json::json!(t);
245 }
246 if let Some(p) = items.top_p {
247 v["top_p"] = serde_json::json!(p);
248 }
249 if let Some(s) = &items.stop_sequences {
250 v["stop"] = serde_json::json!(s);
251 }
252 if items.messages.last().is_some_and(|m| {
257 m.role == Role::Assistant
258 && m.items.iter().any(
259 |i| matches!(i, ContentItem::Text { text } if !text.trim().is_empty()),
260 )
261 && !m
264 .items
265 .iter()
266 .any(|i| matches!(i, ContentItem::ToolCall { .. }))
267 }) {
268 v[crate::canonical::PREFILL_MARKER] = serde_json::json!(true);
269 }
270 if let Some(tools) = tools_to_wire(items) {
271 v["tools"] = tools;
272 }
273 if let Some(tc) = &items.tool_choice {
274 v["tool_choice"] = tool_choice_to_wire(tc);
275 }
276 if let Some(fmt) = &items.response_format {
277 v["response_format"] = response_format_to_wire(fmt);
278 }
279 if let Some(raw) = items.extra.get("thinking").filter(|x| x.is_object()) {
282 v["thinking"] = raw.clone();
283 } else if let Some(th) = &items.thinking {
284 if let Some(b) = th.budget_tokens {
287 v["thinking"] = serde_json::json!({
288 "type": "enabled",
289 "budget_tokens": b,
290 });
291 }
292 if let Some(e) = &th.effort {
293 v["reasoning_effort"] = serde_json::json!(e);
294 }
295 }
296 const RESPONSES_ONLY_EXTRA: &[&str] = &[
301 "background",
302 "conversation",
303 "include",
304 "previous_response_id",
305 "truncation",
306 ];
307 for (k, v2) in &items.extra {
308 if v.get(k).is_none() && !RESPONSES_ONLY_EXTRA.contains(&k.as_str()) {
309 v[k] = v2.clone();
310 }
311 }
312 serde_json::from_value(v).map_err(|e| {
315 ProxyError::Internal(anyhow::anyhow!(
316 "items→chat deflate produced invalid request: {e}"
317 ))
318 })
319}
320
321#[cfg(test)]
322mod items_deflate_tests {
323 use super::*;
324
325 #[test]
328 fn trailing_assistant_text_is_marked_as_prefill() {
329 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
330 "model":"m","max_tokens":100,
331 "messages":[
332 {"role":"user","content":"Name a colour."},
333 {"role":"assistant","content":"The colour is"}
334 ]
335 }))
336 .unwrap();
337 let chat = items_to_chat_request(&items).unwrap();
338 assert_eq!(
339 chat.extra.get(crate::canonical::PREFILL_MARKER),
340 Some(&serde_json::json!(true))
341 );
342 }
343
344 #[test]
345 fn trailing_user_turn_is_not_a_prefill() {
346 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
347 "model":"m","max_tokens":100,
348 "messages":[{"role":"user","content":"hi"}]
349 }))
350 .unwrap();
351 let chat = items_to_chat_request(&items).unwrap();
352 assert!(chat.extra.get(crate::canonical::PREFILL_MARKER).is_none());
353 }
354
355 #[test]
356 fn trailing_tool_call_turn_is_not_a_prefill() {
357 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
359 "model":"m","max_tokens":100,
360 "messages":[
361 {"role":"user","content":"go"},
362 {"role":"assistant","content":[
363 {"type":"text","text":"let me check"},
364 {"type":"tool_use","id":"t1","name":"bash","input":{}}
365 ]}
366 ]
367 }))
368 .unwrap();
369 let chat = items_to_chat_request(&items).unwrap();
370 assert!(chat.extra.get(crate::canonical::PREFILL_MARKER).is_none());
371 }
372
373 #[test]
376 fn responses_tool_loop_never_emits_bare_tool_message() {
377 let raw = serde_json::json!({
378 "model": "m",
379 "input": [
380 {"type":"message","role":"user","content":"run ls"},
381 {"type":"function_call","call_id":"fc_1","name":"bash","arguments":"{}"},
382 {"type":"function_call_output","call_id":"fc_1","output":"file.txt"}
383 ]
384 });
385 let items = crate::dialect::openai_responses::req::from_openai_responses(&raw).unwrap();
386 let chat = items_to_chat_request(&items).unwrap();
387 let v = serde_json::to_value(&chat).unwrap();
388 for msg in v["messages"].as_array().unwrap() {
389 if msg["role"] == "tool" {
390 assert!(
391 msg["tool_call_id"].as_str().is_some_and(|s| !s.is_empty()),
392 "bare tool message without tool_call_id: {msg}"
393 );
394 }
395 }
396 let tools: Vec<_> = v["messages"]
398 .as_array()
399 .unwrap()
400 .iter()
401 .filter(|m| m["role"] == "tool")
402 .collect();
403 assert_eq!(tools.len(), 1);
404 assert_eq!(tools[0]["tool_call_id"], "fc_1");
405 let assistant = v["messages"][1].clone();
407 assert_eq!(assistant["role"], "assistant");
408 assert_eq!(assistant["tool_calls"][0]["id"], "fc_1");
409 }
410
411 #[test]
412 fn tool_result_error_prefix_survives_deflation() {
413 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
414 "model":"m","max_tokens":10,
415 "messages":[
416 {"role":"user","content":"go"},
417 {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"bash","input":{}}]},
418 {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"boom","is_error":true}]}
419 ]
420 }))
421 .unwrap();
422 let chat = items_to_chat_request(&items).unwrap();
423 let tool = chat
424 .messages
425 .iter()
426 .find(|m| m.role == "tool")
427 .expect("tool message");
428 assert_eq!(tool.tool_call_id.as_deref(), Some("t1"));
429 assert!(
430 tool.text().starts_with("[tool error] "),
431 "is_error must annotate the text: {:?}",
432 tool.text()
433 );
434 }
435
436 #[test]
437 fn signed_thinking_becomes_thinking_blocks_unsigned_becomes_reasoning() {
438 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
439 "model":"m","max_tokens":100,
440 "messages":[
441 {"role":"user","content":"hi"},
442 {"role":"assistant","content":[
443 {"type":"thinking","thinking":"plan","signature":"sig1"},
444 {"type":"text","text":"answer"}
445 ]}
446 ]
447 }))
448 .unwrap();
449 let chat = items_to_chat_request(&items).unwrap();
450 let assistant = &chat.messages[1];
451 let blocks = assistant.thinking_blocks.as_ref().expect("thinking_blocks");
452 assert_eq!(blocks[0]["signature"], "sig1");
453 assert_eq!(assistant.text(), "answer");
454 assert!(assistant.tool_calls.is_none());
455 assert!(assistant.reasoning_content.is_none());
457 }
458
459 #[test]
460 fn thinking_disabled_stays_disabled() {
461 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
462 "model":"m","max_tokens":100,
463 "thinking":{"type":"disabled"},
464 "messages":[{"role":"user","content":"hi"}]
465 }))
466 .unwrap();
467 let chat = items_to_chat_request(&items).unwrap();
468 assert_eq!(
469 chat.extra.get("thinking"),
470 Some(&serde_json::json!({"type":"disabled"})),
471 "disabled must not flip to enabled"
472 );
473 }
474
475 #[test]
476 fn no_cap_means_no_max_tokens_field() {
477 let raw = serde_json::json!({"model":"m","input":"hi"});
478 let items = crate::dialect::openai_responses::req::from_openai_responses(&raw).unwrap();
479 let chat = items_to_chat_request(&items).unwrap();
480 assert!(chat.max_tokens.is_none());
481 }
482
483 #[test]
484 fn per_message_name_survives_deflation() {
485 let raw = serde_json::json!({
486 "model": "m",
487 "messages": [
488 {"role":"user","name":"alice","content":"hi"},
489 {"role":"assistant","name":"bot","content":"yo"},
490 ]
491 });
492 let items = crate::dialect::openai_chat::req::from_openai_chat(&raw).unwrap();
493 let chat = items_to_chat_request(&items).unwrap();
494 let v = serde_json::to_value(&chat).unwrap();
495 assert_eq!(v["messages"][0]["name"], "alice");
496 assert_eq!(v["messages"][1]["name"], "bot");
497 }
498
499 #[test]
500 fn responses_only_extras_do_not_reach_chat_upstreams() {
501 let raw = serde_json::json!({
502 "model": "m",
503 "input": "hi",
504 "background": true,
505 "previous_response_id": "resp_1",
506 "conversation": "conv_1",
507 "include": ["output_logprobs"],
508 "truncation": "auto",
509 "store": false,
510 "metadata": {"k":"v"},
511 "stream_options": {"include_usage": true},
512 });
513 let items = crate::dialect::openai_responses::req::from_openai_responses(&raw).unwrap();
514 let chat = items_to_chat_request(&items).unwrap();
515 for k in [
516 "background",
517 "previous_response_id",
518 "conversation",
519 "include",
520 "truncation",
521 ] {
522 assert!(!chat.extra.contains_key(k), "{k} must be dropped");
523 }
524 assert!(chat.extra.contains_key("store"));
526 assert!(chat.extra.contains_key("metadata"));
527 assert!(chat.stream_options.is_some());
528 }
529
530 #[test]
531 fn non_text_tool_result_content_is_a_hard_error() {
532 let mut items =
536 crate::dialect::openai_responses::req::from_openai_responses(&serde_json::json!({
537 "model": "m",
538 "input": [
539 {"type":"function_call","call_id":"fc_1","name":"bash","arguments":"{}"},
540 {"type":"function_call_output","call_id":"fc_1","output":"ok"}
541 ]
542 }))
543 .unwrap();
544 for m in &mut items.messages {
545 for it in &mut m.items {
546 if let ContentItem::ToolResult { content, .. } = it {
547 *content = serde_json::json!({"structured": true});
548 }
549 }
550 }
551 assert!(items_to_chat_request(&items).is_err());
552 }
553
554 #[test]
555 fn unsigned_thinking_deflates_to_reasoning_not_null_signed_block() {
556 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
557 "model":"m","max_tokens":100,
558 "messages":[
559 {"role":"user","content":"hi"},
560 {"role":"assistant","content":[
561 {"type":"thinking","thinking":"plan"},
562 {"type":"text","text":"answer"}
563 ]}
564 ]
565 }))
566 .unwrap();
567 let chat = items_to_chat_request(&items).unwrap();
568 let assistant = &chat.messages[1];
569 let v = serde_json::to_value(assistant).unwrap();
570 assert!(
571 v.get("thinking_blocks").is_none(),
572 "unsigned thinking must not emit a thinking block: {v}"
573 );
574 assert_eq!(assistant.reasoning_content.as_deref(), Some("plan"));
575 }
576
577 #[test]
578 fn tool_message_precedes_coalesced_user_text() {
579 let items = crate::dialect::anthropic::req::from_anthropic(&serde_json::json!({
582 "model":"m","max_tokens":100,
583 "messages":[
584 {"role":"user","content":"go"},
585 {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"bash","input":{}}]},
586 {"role":"user","content":[
587 {"type":"text","text":"note to self"},
588 {"type":"tool_result","tool_use_id":"t1","content":"out"}
589 ]}
590 ]
591 }))
592 .unwrap();
593 let chat = items_to_chat_request(&items).unwrap();
594 let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
595 assert_eq!(roles, vec!["user", "assistant", "tool", "user"]);
596 assert_eq!(chat.messages[2].tool_call_id.as_deref(), Some("t1"));
597 assert_eq!(chat.messages[3].text(), "note to self");
598 }
599}