Skip to main content

llm_dialect/dialect/
deflate.rs

1//! Deflate: the items canonical → legacy OpenAI-shaped ChatRequest.
2//!
3//! Pure data transformation — no I/O, no runtime (enforced by the purity
4//! lint): an accumulator folds each inbound message's content items into an
5//! OpenAI wire message, tool results split off as role:"tool" messages, and
6//! typed cfg fields / extras map onto the ChatRequest the pipeline consumes.
7//! Moved verbatim from proxy_api.rs (its former home); lives here because it
8//! operates on the items model that owns this tree.
9
10use crate::canonical::ChatRequest;
11use crate::error::ProxyError;
12use crate::items::{ContentItem, ItemRequest, ResponseFormat, Role};
13
14/// Accumulator for one inbound message as it folds into an OpenAI-dialect
15/// wire message. `saw_payload` marks whether anything was folded at all (so
16/// empty messages never emit a bare `{"role": ...}`).
17#[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    /// Fold a single content item; refuses non-text tool results with 400.
28    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                // Tool messages must precede the coalesced user text (emitted
81                // at message end) — OpenAI upstreams require tool messages to
82                // follow the assistant tool_calls turn immediately.
83                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    /// Emit the accumulated wire message if anything was folded into it.
118    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                // legacy parity: content present unless the turn is
136                // tool-call-only (bare null content upstreams reject the
137                // explicit null less often than a missing key)
138                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
161/// Canonical tool set → OpenAI `tools` wire array.
162fn 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
211/// Deflate the items canonical back into the legacy OpenAI-shaped ChatRequest
212/// the pipeline consumes. Item order is semantic; tool results split off as
213/// role:"tool" messages (each carrying its tool_call_id — a bare role:"tool"
214/// message 400s on every real upstream).
215pub 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            // a canonical tool message never becomes a wire role:"tool"
223            // framing message (no tool_call_id on it); its results split off
224            // below, so any accompanying text surfaces as a user turn
225            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    // A trailing assistant turn carrying text is Anthropic's prefill: the
253    // model must continue that text rather than open a new turn. Marked here
254    // because only the canonical knows the shape; the provider decides what
255    // the upstream can be told (see providers::openai::request_body).
256    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            // a turn still holding tool calls is a mid-loop replay awaiting
262            // results, not a prefix the model is meant to continue
263            && !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    // an Anthropic-origin `thinking` object (possibly {"type":"disabled"})
280    // rides in extra and must win over the typed cfg
281    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        // only Anthropic-shaped thinking with a real budget is emittable;
285        // effort-only maps to reasoning_effort for the dialects that take it
286        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    // extras fill gaps; never clobber the keys this translator just computed.
297    // Responses-only fields (captured from that surface's wildcard extras)
298    // are dropped here: they 400 on chat-completions upstreams, and the
299    // Responses provider never reads them out of the deflated request.
300    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    // Client-derived extras are flattened into ChatRequest fields here; a
313    // typed-field collision must surface as a 500 body, not a request-task panic.
314    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    /// Anthropic prefill: the trailing assistant turn is a prefix to continue,
326    /// and plain OpenAI chat cannot say so on its own.
327    #[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        // mid-loop replay awaiting tool results, not a prefix to continue
358        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    /// C1 regression: a Responses tool round-trip must never emit a bare
374    /// role:"tool" message (no tool_call_id) — upstreams 400 on those.
375    #[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        // exactly one tool message, paired with fc_1
397        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        // the assistant turn carries the call
406        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        // and no reasoning_content alias for signed blocks
456        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        // chat-legal fields survive; stream_options lands in the typed field
525        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        // hand-built: every dialect req adapter coerces to strings, so this
533        // guards against future ingress paths shipping structured content
534        // that would silently stringify
535        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        // OpenAI hard-400s on a user message inserted between the assistant
580        // tool_calls turn and its tool results.
581        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}