Skip to main content

lean_ctx/proxy/
shape_xlat.rs

1//! Cross-shape translation Anthropic ↔ OpenAI (enterprise#16, feature `shape-xlat`).
2//!
3//! Lets a Claude client (`POST /v1/messages`) transparently use an
4//! OpenAI-shape upstream (Azure AI Foundry, vLLM, Ollama, Groq…): the request
5//! is rewritten Messages→Chat-Completions before it leaves, and the response —
6//! streaming or not — is rewritten back so the caller's Anthropic SDK never
7//! notices. Pure enabler for the router (`[proxy.routing]` cross-shape
8//! targets), deliberately not a headline feature (scope gate `12` §6).
9//!
10//! Mapping summary:
11//!
12//! | Anthropic                              | OpenAI                                  |
13//! |----------------------------------------|-----------------------------------------|
14//! | `system` (string/blocks)               | leading `role:"system"` message         |
15//! | `tool_use` block (assistant)           | `tool_calls[]` entry                    |
16//! | `tool_result` block (user)             | `role:"tool"` message                   |
17//! | `image` block (base64)                 | `image_url` part (data URL)             |
18//! | `tools[].input_schema`                 | `tools[].function.parameters`           |
19//! | `tool_choice {auto,any,tool}`          | `"auto"`, `"required"`, function pick   |
20//! | `stop_sequences`                       | `stop`                                  |
21//! | `metadata.user_id`                     | `user`                                  |
22//! | `cache_control`                        | stripped (OpenAI caches implicitly)     |
23//! | response `content[]` / SSE events      | `choices[0].message` / `delta` chunks   |
24//! | `usage.prompt_tokens(_details.cached)` | `input_tokens` / `cache_read_…`         |
25//!
26//! Streaming is a stateful SSE-to-SSE rewrite ([`StreamXlat`]): OpenAI
27//! `chat.completion.chunk` deltas become `message_start` /
28//! `content_block_start|delta|stop` / `message_delta` / `message_stop` events.
29//! Usage metering does NOT read the translated stream — the forward path scans
30//! the raw upstream bytes with the OpenAI scanner before translation.
31
32use serde_json::{Value, json};
33
34// ─── Request: Anthropic Messages → OpenAI Chat Completions ──────────────────
35
36/// Translates a parsed Anthropic Messages request into an OpenAI Chat
37/// Completions body. `None` = not translatable (caller must fail open and
38/// forward natively).
39#[must_use]
40pub fn messages_to_chat(anthropic: &Value) -> Option<Value> {
41    let model = anthropic.get("model")?.as_str()?;
42    let src_messages = anthropic.get("messages")?.as_array()?;
43
44    let mut messages: Vec<Value> = Vec::with_capacity(src_messages.len() + 1);
45    if let Some(system) = anthropic.get("system")
46        && let Some(text) = system_text(system)
47    {
48        messages.push(json!({"role": "system", "content": text}));
49    }
50    for msg in src_messages {
51        translate_message(msg, &mut messages)?;
52    }
53
54    let mut out = json!({"model": model, "messages": messages});
55    let o = out.as_object_mut().expect("constructed as object");
56
57    if let Some(v) = anthropic.get("max_tokens").and_then(Value::as_u64) {
58        o.insert("max_tokens".into(), json!(v));
59    }
60    for key in ["temperature", "top_p"] {
61        if let Some(v) = anthropic.get(key).and_then(Value::as_f64) {
62            o.insert(key.into(), json!(v));
63        }
64    }
65    if let Some(stops) = anthropic.get("stop_sequences").and_then(Value::as_array)
66        && !stops.is_empty()
67    {
68        o.insert("stop".into(), Value::Array(stops.clone()));
69    }
70    if let Some(user) = anthropic
71        .pointer("/metadata/user_id")
72        .and_then(Value::as_str)
73    {
74        o.insert("user".into(), json!(user));
75    }
76    if let Some(tools) = anthropic.get("tools").and_then(Value::as_array) {
77        let translated: Vec<Value> = tools.iter().filter_map(translate_tool).collect();
78        if !translated.is_empty() {
79            o.insert("tools".into(), Value::Array(translated));
80        }
81    }
82    if let Some(choice) = anthropic.get("tool_choice")
83        && let Some(mapped) = translate_tool_choice(choice)
84    {
85        o.insert("tool_choice".into(), mapped);
86    }
87    if anthropic.get("stream").and_then(Value::as_bool) == Some(true) {
88        o.insert("stream".into(), json!(true));
89        // The final chunk must carry usage — metering and the translated
90        // message_delta both need it.
91        o.insert("stream_options".into(), json!({"include_usage": true}));
92    }
93    Some(out)
94}
95
96/// Anthropic `system` is a plain string or an array of text blocks.
97fn system_text(system: &Value) -> Option<String> {
98    if let Some(s) = system.as_str() {
99        return Some(s.to_string());
100    }
101    let parts: Vec<&str> = system
102        .as_array()?
103        .iter()
104        .filter_map(|b| b.get("text").and_then(Value::as_str))
105        .collect();
106    if parts.is_empty() {
107        None
108    } else {
109        Some(parts.join("\n\n"))
110    }
111}
112
113/// Translates one Anthropic message into 1..n OpenAI messages, appended to
114/// `out`. `tool_result` blocks become individual `role:"tool"` messages (they
115/// answer the assistant's `tool_calls` from the previous turn and must come
116/// first); remaining user content follows as one user message.
117fn translate_message(msg: &Value, out: &mut Vec<Value>) -> Option<()> {
118    let role = msg.get("role")?.as_str()?;
119    let content = msg.get("content")?;
120
121    if let Some(text) = content.as_str() {
122        out.push(json!({"role": role, "content": text}));
123        return Some(());
124    }
125    let blocks = content.as_array()?;
126    if role == "assistant" {
127        translate_assistant_blocks(blocks, out)
128    } else {
129        translate_user_blocks(blocks, out);
130        Some(())
131    }
132}
133
134/// Assistant turn: text blocks join into `content`, `tool_use` blocks become
135/// `tool_calls`; thinking blocks have no OpenAI equivalent and are dropped.
136fn translate_assistant_blocks(blocks: &[Value], out: &mut Vec<Value>) -> Option<()> {
137    let mut text = String::new();
138    let mut tool_calls: Vec<Value> = Vec::new();
139    for b in blocks {
140        match b.get("type").and_then(Value::as_str) {
141            Some("text") => {
142                if let Some(t) = b.get("text").and_then(Value::as_str) {
143                    if !text.is_empty() {
144                        text.push('\n');
145                    }
146                    text.push_str(t);
147                }
148            }
149            Some("tool_use") => {
150                let args = b.get("input").cloned().unwrap_or_else(|| json!({}));
151                tool_calls.push(json!({
152                    "id": b.get("id").and_then(Value::as_str).unwrap_or_default(),
153                    "type": "function",
154                    "function": {
155                        "name": b.get("name").and_then(Value::as_str).unwrap_or_default(),
156                        "arguments": serde_json::to_string(&args).ok()?,
157                    }
158                }));
159            }
160            _ => {}
161        }
162    }
163    let mut m = json!({"role": "assistant"});
164    let mo = m.as_object_mut().expect("object");
165    mo.insert(
166        "content".into(),
167        if text.is_empty() {
168            Value::Null
169        } else {
170            json!(text)
171        },
172    );
173    if !tool_calls.is_empty() {
174        mo.insert("tool_calls".into(), Value::Array(tool_calls));
175    }
176    out.push(m);
177    Some(())
178}
179
180/// User turn: `tool_result` blocks become `role:"tool"` messages (first — they
181/// answer the previous assistant `tool_calls`), text/image parts follow as one
182/// user message.
183fn translate_user_blocks(blocks: &[Value], out: &mut Vec<Value>) {
184    let mut parts: Vec<Value> = Vec::new();
185    let mut plain_text_only = true;
186    for b in blocks {
187        match b.get("type").and_then(Value::as_str) {
188            Some("tool_result") => {
189                out.push(json!({
190                    "role": "tool",
191                    "tool_call_id": b.get("tool_use_id").and_then(Value::as_str).unwrap_or_default(),
192                    "content": tool_result_text(b),
193                }));
194            }
195            Some("text") => {
196                if let Some(t) = b.get("text").and_then(Value::as_str) {
197                    parts.push(json!({"type": "text", "text": t}));
198                }
199            }
200            Some("image") => {
201                plain_text_only = false;
202                if let (Some(mt), Some(data)) = (
203                    b.pointer("/source/media_type").and_then(Value::as_str),
204                    b.pointer("/source/data").and_then(Value::as_str),
205                ) {
206                    parts.push(json!({
207                        "type": "image_url",
208                        "image_url": {"url": format!("data:{mt};base64,{data}")}
209                    }));
210                }
211            }
212            _ => {}
213        }
214    }
215    if !parts.is_empty() {
216        let content = if plain_text_only {
217            // Collapse to a plain string — maximum upstream compat.
218            let joined: Vec<&str> = parts
219                .iter()
220                .filter_map(|p| p.get("text").and_then(Value::as_str))
221                .collect();
222            json!(joined.join("\n"))
223        } else {
224            Value::Array(parts)
225        };
226        out.push(json!({"role": "user", "content": content}));
227    }
228}
229
230/// Flattens a `tool_result`'s content (string or text blocks) to plain text.
231fn tool_result_text(block: &Value) -> String {
232    match block.get("content") {
233        Some(Value::String(s)) => s.clone(),
234        Some(Value::Array(blocks)) => blocks
235            .iter()
236            .filter_map(|b| b.get("text").and_then(Value::as_str))
237            .collect::<Vec<_>>()
238            .join("\n"),
239        _ => String::new(),
240    }
241}
242
243fn translate_tool(tool: &Value) -> Option<Value> {
244    let name = tool.get("name")?.as_str()?;
245    let mut function = json!({
246        "name": name,
247        "parameters": tool.get("input_schema").cloned().unwrap_or_else(|| json!({"type": "object"})),
248    });
249    if let Some(desc) = tool.get("description").and_then(Value::as_str) {
250        function["description"] = json!(desc);
251    }
252    Some(json!({"type": "function", "function": function}))
253}
254
255fn translate_tool_choice(choice: &Value) -> Option<Value> {
256    match choice.get("type").and_then(Value::as_str)? {
257        "auto" => Some(json!("auto")),
258        "any" => Some(json!("required")),
259        "none" => Some(json!("none")),
260        "tool" => {
261            let name = choice.get("name")?.as_str()?;
262            Some(json!({"type": "function", "function": {"name": name}}))
263        }
264        _ => None,
265    }
266}
267
268// ─── Response (non-streaming): chat.completion → Anthropic message ──────────
269
270/// Translates a full OpenAI `chat.completion` body into an Anthropic message.
271/// `None` = body not recognizable (caller forwards it unchanged).
272#[must_use]
273pub fn chat_to_messages(openai: &Value) -> Option<Value> {
274    let choice = openai.get("choices")?.as_array()?.first()?;
275    let message = choice.get("message")?;
276
277    let mut content: Vec<Value> = Vec::new();
278    if let Some(text) = message.get("content").and_then(Value::as_str)
279        && !text.is_empty()
280    {
281        content.push(json!({"type": "text", "text": text}));
282    }
283    if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
284        for call in calls {
285            let f = call.get("function")?;
286            let args: Value = f
287                .get("arguments")
288                .and_then(Value::as_str)
289                .and_then(|s| serde_json::from_str(s).ok())
290                .unwrap_or_else(|| json!({}));
291            content.push(json!({
292                "type": "tool_use",
293                "id": call.get("id").and_then(Value::as_str).unwrap_or_default(),
294                "name": f.get("name").and_then(Value::as_str).unwrap_or_default(),
295                "input": args,
296            }));
297        }
298    }
299
300    let finish = choice.get("finish_reason").and_then(Value::as_str);
301    let id = openai.get("id").and_then(Value::as_str).unwrap_or("xlat");
302    Some(json!({
303        "id": format!("msg_{id}"),
304        "type": "message",
305        "role": "assistant",
306        "model": openai.get("model").and_then(Value::as_str).unwrap_or_default(),
307        "content": content,
308        "stop_reason": stop_reason(finish),
309        "stop_sequence": Value::Null,
310        "usage": usage_to_anthropic(openai.get("usage")),
311    }))
312}
313
314/// Maps an OpenAI error envelope (`{"error": {...}}`) to the Anthropic one
315/// (`{"type":"error","error":{...}}`) so the caller's SDK renders the message.
316#[must_use]
317pub fn error_to_anthropic(openai: &Value) -> Option<Value> {
318    let err = openai.get("error")?;
319    let message = err.get("message").and_then(Value::as_str).unwrap_or("");
320    let kind = match err.get("type").and_then(Value::as_str) {
321        Some("insufficient_quota" | "rate_limit_error" | "requests" | "tokens") => {
322            "rate_limit_error"
323        }
324        Some("invalid_request_error") => "invalid_request_error",
325        Some("authentication_error") => "authentication_error",
326        _ => "api_error",
327    };
328    Some(json!({
329        "type": "error",
330        "error": {"type": kind, "message": message},
331    }))
332}
333
334fn stop_reason(finish: Option<&str>) -> &'static str {
335    match finish {
336        Some("length") => "max_tokens",
337        Some("tool_calls" | "function_call") => "tool_use",
338        // stop / content_filter / unknown all end the turn.
339        _ => "end_turn",
340    }
341}
342
343fn usage_to_anthropic(usage: Option<&Value>) -> Value {
344    let prompt = usage
345        .and_then(|u| u.get("prompt_tokens"))
346        .and_then(Value::as_u64)
347        .unwrap_or(0);
348    let completion = usage
349        .and_then(|u| u.get("completion_tokens"))
350        .and_then(Value::as_u64)
351        .unwrap_or(0);
352    let cached = usage
353        .and_then(|u| u.pointer("/prompt_tokens_details/cached_tokens"))
354        .and_then(Value::as_u64)
355        .unwrap_or(0);
356    json!({
357        // Anthropic reports input EXCLUDING cache reads; OpenAI's prompt_tokens
358        // includes them.
359        "input_tokens": prompt.saturating_sub(cached),
360        "output_tokens": completion,
361        "cache_read_input_tokens": cached,
362        "cache_creation_input_tokens": 0,
363    })
364}
365
366// ─── Response (streaming): chat.completion.chunk SSE → Anthropic SSE ────────
367
368/// Which content block is currently open on the translated (Anthropic) side.
369#[derive(Debug, PartialEq)]
370enum OpenBlock {
371    Text,
372    /// OpenAI `tool_calls[].index` this block translates.
373    Tool(u64),
374}
375
376/// Stateful OpenAI→Anthropic SSE translator. Feed raw upstream bytes, get
377/// translated Anthropic event bytes; call [`StreamXlat::finish`] at stream end
378/// to flush the closing events if the upstream never sent `[DONE]`.
379#[derive(Default)]
380pub struct StreamXlat {
381    line_buf: Vec<u8>,
382    started: bool,
383    finished: bool,
384    block_index: u64,
385    open: Option<OpenBlock>,
386    finish_reason: Option<String>,
387    usage: Option<Value>,
388}
389
390/// Bound for a buffered partial SSE line (matches the usage scanner's guard).
391const MAX_LINE_BYTES: usize = 1 << 20;
392
393impl StreamXlat {
394    /// Consumes one upstream chunk, returns the translated bytes (possibly empty).
395    pub fn feed(&mut self, chunk: &[u8]) -> Vec<u8> {
396        let mut out = Vec::new();
397        for byte in chunk {
398            if *byte == b'\n' {
399                let line = std::mem::take(&mut self.line_buf);
400                self.process_line(&line, &mut out);
401            } else if self.line_buf.len() < MAX_LINE_BYTES {
402                self.line_buf.push(*byte);
403            }
404        }
405        out
406    }
407
408    /// Flushes the closing events. Idempotent.
409    pub fn finish(&mut self) -> Vec<u8> {
410        let mut out = Vec::new();
411        self.emit_closing(&mut out);
412        out
413    }
414
415    fn process_line(&mut self, line: &[u8], out: &mut Vec<u8>) {
416        let line = std::str::from_utf8(line).unwrap_or("").trim();
417        let Some(data) = line.strip_prefix("data:").map(str::trim) else {
418            return; // event:/comment/empty lines carry nothing we need
419        };
420        if data == "[DONE]" {
421            self.emit_closing(out);
422            return;
423        }
424        let Ok(chunk) = serde_json::from_str::<Value>(data) else {
425            return;
426        };
427
428        // Usage-only final chunk (stream_options.include_usage).
429        if let Some(usage) = chunk.get("usage")
430            && !usage.is_null()
431        {
432            self.usage = Some(usage_to_anthropic(Some(usage)));
433        }
434
435        if !self.started {
436            self.emit_message_start(&chunk, out);
437        }
438
439        let Some(choice) = chunk
440            .get("choices")
441            .and_then(Value::as_array)
442            .and_then(|c| c.first())
443        else {
444            return;
445        };
446        if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
447            self.finish_reason = Some(reason.to_string());
448        }
449        let Some(delta) = choice.get("delta") else {
450            return;
451        };
452
453        if let Some(text) = delta.get("content").and_then(Value::as_str)
454            && !text.is_empty()
455        {
456            if self.open != Some(OpenBlock::Text) {
457                self.close_block(out);
458                emit_event(
459                    out,
460                    "content_block_start",
461                    &json!({
462                        "type": "content_block_start",
463                        "index": self.block_index,
464                        "content_block": {"type": "text", "text": ""},
465                    }),
466                );
467                self.open = Some(OpenBlock::Text);
468            }
469            emit_event(
470                out,
471                "content_block_delta",
472                &json!({
473                    "type": "content_block_delta",
474                    "index": self.block_index,
475                    "delta": {"type": "text_delta", "text": text},
476                }),
477            );
478        }
479
480        if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
481            for call in calls {
482                self.process_tool_delta(call, out);
483            }
484        }
485    }
486
487    fn process_tool_delta(&mut self, call: &Value, out: &mut Vec<u8>) {
488        let idx = call.get("index").and_then(Value::as_u64).unwrap_or(0);
489        let name = call.pointer("/function/name").and_then(Value::as_str);
490
491        // A named entry starts a new tool call; bare-arguments entries continue
492        // the block already open for that index.
493        let continues = self.open == Some(OpenBlock::Tool(idx)) && name.is_none();
494        if !continues {
495            self.close_block(out);
496            emit_event(
497                out,
498                "content_block_start",
499                &json!({
500                    "type": "content_block_start",
501                    "index": self.block_index,
502                    "content_block": {
503                        "type": "tool_use",
504                        "id": call.get("id").and_then(Value::as_str).unwrap_or_default(),
505                        "name": name.unwrap_or_default(),
506                        "input": {},
507                    },
508                }),
509            );
510            self.open = Some(OpenBlock::Tool(idx));
511        }
512        if let Some(args) = call.pointer("/function/arguments").and_then(Value::as_str)
513            && !args.is_empty()
514        {
515            emit_event(
516                out,
517                "content_block_delta",
518                &json!({
519                    "type": "content_block_delta",
520                    "index": self.block_index,
521                    "delta": {"type": "input_json_delta", "partial_json": args},
522                }),
523            );
524        }
525    }
526
527    fn emit_message_start(&mut self, chunk: &Value, out: &mut Vec<u8>) {
528        self.started = true;
529        let id = chunk.get("id").and_then(Value::as_str).unwrap_or("xlat");
530        let model = chunk.get("model").and_then(Value::as_str).unwrap_or("");
531        emit_event(
532            out,
533            "message_start",
534            &json!({
535                "type": "message_start",
536                "message": {
537                    "id": format!("msg_{id}"),
538                    "type": "message",
539                    "role": "assistant",
540                    "model": model,
541                    "content": [],
542                    "stop_reason": Value::Null,
543                    "stop_sequence": Value::Null,
544                    // Real numbers arrive with the final usage chunk and are
545                    // delivered via message_delta (SDKs merge cumulatively).
546                    "usage": {"input_tokens": 0, "output_tokens": 0},
547                },
548            }),
549        );
550    }
551
552    fn close_block(&mut self, out: &mut Vec<u8>) {
553        if self.open.take().is_some() {
554            emit_event(
555                out,
556                "content_block_stop",
557                &json!({"type": "content_block_stop", "index": self.block_index}),
558            );
559            self.block_index += 1;
560        }
561    }
562
563    fn emit_closing(&mut self, out: &mut Vec<u8>) {
564        if self.finished {
565            return;
566        }
567        self.finished = true;
568        if !self.started {
569            // Upstream produced nothing usable; still emit a valid envelope.
570            self.emit_message_start(&json!({}), out);
571        }
572        self.close_block(out);
573        let usage = self
574            .usage
575            .take()
576            .unwrap_or_else(|| json!({"output_tokens": 0}));
577        emit_event(
578            out,
579            "message_delta",
580            &json!({
581                "type": "message_delta",
582                "delta": {
583                    "stop_reason": stop_reason(self.finish_reason.as_deref()),
584                    "stop_sequence": Value::Null,
585                },
586                "usage": usage,
587            }),
588        );
589        emit_event(out, "message_stop", &json!({"type": "message_stop"}));
590    }
591}
592
593fn emit_event(out: &mut Vec<u8>, event: &str, data: &Value) {
594    out.extend_from_slice(b"event: ");
595    out.extend_from_slice(event.as_bytes());
596    out.extend_from_slice(b"\ndata: ");
597    out.extend_from_slice(data.to_string().as_bytes());
598    out.extend_from_slice(b"\n\n");
599}
600
601/// Wraps an (already usage-teed) OpenAI SSE byte stream into the translated
602/// Anthropic SSE stream. Chunks that translate to nothing are skipped; on
603/// upstream end the closing events are flushed.
604pub fn to_anthropic_stream<S, B, E>(
605    inner: S,
606) -> impl futures::Stream<Item = Result<Vec<u8>, E>> + Send + 'static
607where
608    S: futures::Stream<Item = Result<B, E>> + Send + Unpin + 'static,
609    B: AsRef<[u8]> + Send + 'static,
610    E: Send + 'static,
611{
612    use futures::StreamExt;
613    futures::stream::unfold(
614        (inner, StreamXlat::default(), false),
615        |(mut inner, mut xlat, ended)| async move {
616            if ended {
617                return None;
618            }
619            loop {
620                match inner.next().await {
621                    Some(Ok(chunk)) => {
622                        let translated = xlat.feed(chunk.as_ref());
623                        if translated.is_empty() {
624                            continue; // nothing translatable in this chunk yet
625                        }
626                        return Some((Ok(translated), (inner, xlat, false)));
627                    }
628                    Some(Err(e)) => return Some((Err(e), (inner, xlat, false))),
629                    None => {
630                        let tail = xlat.finish();
631                        if tail.is_empty() {
632                            return None;
633                        }
634                        return Some((Ok(tail), (inner, xlat, true)));
635                    }
636                }
637            }
638        },
639    )
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    // ── Request direction ────────────────────────────────────────────────
647
648    fn full_anthropic_request() -> Value {
649        json!({
650            "model": "gpt-4o-mini",
651            "max_tokens": 1024,
652            "temperature": 0.5,
653            "stop_sequences": ["END"],
654            "metadata": {"user_id": "u-42"},
655            "system": [
656                {"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}}
657            ],
658            "messages": [
659                {"role": "user", "content": "What's the weather in Zurich?"},
660                {"role": "assistant", "content": [
661                    {"type": "text", "text": "Let me check."},
662                    {"type": "tool_use", "id": "toolu_1", "name": "get_weather",
663                     "input": {"city": "Zurich"}}
664                ]},
665                {"role": "user", "content": [
666                    {"type": "tool_result", "tool_use_id": "toolu_1",
667                     "content": [{"type": "text", "text": "18°C, sunny"}]},
668                    {"type": "text", "text": "And tomorrow?"}
669                ]}
670            ],
671            "tools": [
672                {"name": "get_weather", "description": "Weather lookup",
673                 "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}},
674                 "cache_control": {"type": "ephemeral"}}
675            ],
676            "tool_choice": {"type": "auto"},
677            "stream": true
678        })
679    }
680
681    #[test]
682    fn request_maps_system_tools_and_history() {
683        let out = messages_to_chat(&full_anthropic_request()).expect("translates");
684        let messages = out["messages"].as_array().unwrap();
685
686        assert_eq!(messages[0]["role"], "system");
687        assert_eq!(messages[0]["content"], "You are helpful.");
688        assert_eq!(messages[1]["role"], "user");
689        assert_eq!(messages[1]["content"], "What's the weather in Zurich?");
690
691        // Assistant turn: text + tool_use → content + tool_calls.
692        assert_eq!(messages[2]["role"], "assistant");
693        assert_eq!(messages[2]["content"], "Let me check.");
694        let call = &messages[2]["tool_calls"][0];
695        assert_eq!(call["id"], "toolu_1");
696        assert_eq!(call["function"]["name"], "get_weather");
697        let args: Value = serde_json::from_str(call["function"]["arguments"].as_str().unwrap())
698            .expect("arguments are a JSON string");
699        assert_eq!(args["city"], "Zurich");
700
701        // tool_result → role:"tool" message BEFORE the follow-up user text.
702        assert_eq!(messages[3]["role"], "tool");
703        assert_eq!(messages[3]["tool_call_id"], "toolu_1");
704        assert_eq!(messages[3]["content"], "18°C, sunny");
705        assert_eq!(messages[4]["role"], "user");
706        assert_eq!(messages[4]["content"], "And tomorrow?");
707
708        // Tools + params.
709        assert_eq!(out["tools"][0]["type"], "function");
710        assert_eq!(out["tools"][0]["function"]["name"], "get_weather");
711        assert!(out["tools"][0]["function"]["parameters"]["properties"]["city"].is_object());
712        assert_eq!(out["tool_choice"], "auto");
713        assert_eq!(out["max_tokens"], 1024);
714        assert_eq!(out["stop"][0], "END");
715        assert_eq!(out["user"], "u-42");
716        assert_eq!(out["stream"], true);
717        assert_eq!(out["stream_options"]["include_usage"], true);
718        // cache_control never crosses shapes.
719        assert!(out.to_string().find("cache_control").is_none());
720    }
721
722    #[test]
723    fn request_maps_images_and_forced_tool_choice() {
724        let body = json!({
725            "model": "gpt-4o", "max_tokens": 100,
726            "tool_choice": {"type": "tool", "name": "extract"},
727            "messages": [{"role": "user", "content": [
728                {"type": "text", "text": "Describe this"},
729                {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}}
730            ]}]
731        });
732        let out = messages_to_chat(&body).unwrap();
733        let content = out["messages"][0]["content"].as_array().unwrap();
734        assert_eq!(content[0]["type"], "text");
735        assert_eq!(content[1]["type"], "image_url");
736        assert_eq!(content[1]["image_url"]["url"], "data:image/png;base64,AAAA");
737        assert_eq!(out["tool_choice"]["function"]["name"], "extract");
738    }
739
740    #[test]
741    fn untranslatable_bodies_return_none() {
742        assert!(messages_to_chat(&json!({"model": "m"})).is_none());
743        assert!(messages_to_chat(&json!({"messages": []})).is_none());
744    }
745
746    // ── Response direction (non-streaming) ───────────────────────────────
747
748    #[test]
749    fn response_maps_text_tools_and_cached_usage() {
750        let openai = json!({
751            "id": "chatcmpl-9x", "object": "chat.completion", "model": "gpt-4o-mini",
752            "choices": [{"index": 0, "finish_reason": "tool_calls", "message": {
753                "role": "assistant", "content": "Checking.",
754                "tool_calls": [{"id": "call_7", "type": "function",
755                    "function": {"name": "get_weather", "arguments": "{\"city\":\"Zurich\"}"}}]
756            }}],
757            "usage": {"prompt_tokens": 120, "completion_tokens": 30,
758                      "prompt_tokens_details": {"cached_tokens": 100}}
759        });
760        let msg = chat_to_messages(&openai).expect("translates");
761        assert_eq!(msg["type"], "message");
762        assert_eq!(msg["id"], "msg_chatcmpl-9x");
763        assert_eq!(msg["model"], "gpt-4o-mini");
764        assert_eq!(msg["stop_reason"], "tool_use");
765        assert_eq!(msg["content"][0]["type"], "text");
766        assert_eq!(msg["content"][0]["text"], "Checking.");
767        assert_eq!(msg["content"][1]["type"], "tool_use");
768        assert_eq!(msg["content"][1]["id"], "call_7");
769        assert_eq!(msg["content"][1]["input"]["city"], "Zurich");
770        // OpenAI prompt_tokens INCLUDES cached; Anthropic input_tokens excludes.
771        assert_eq!(msg["usage"]["input_tokens"], 20);
772        assert_eq!(msg["usage"]["output_tokens"], 30);
773        assert_eq!(msg["usage"]["cache_read_input_tokens"], 100);
774    }
775
776    #[test]
777    fn error_envelope_translates_to_anthropic_shape() {
778        let openai = json!({"error": {"message": "quota exceeded",
779            "type": "insufficient_quota", "code": "insufficient_quota"}});
780        let anthropic = error_to_anthropic(&openai).unwrap();
781        assert_eq!(anthropic["type"], "error");
782        assert_eq!(anthropic["error"]["type"], "rate_limit_error");
783        assert_eq!(anthropic["error"]["message"], "quota exceeded");
784        assert!(error_to_anthropic(&json!({"ok": true})).is_none());
785    }
786
787    // ── Streaming direction ──────────────────────────────────────────────
788
789    /// Collects `(event, data)` pairs from translated SSE bytes.
790    fn parse_events(bytes: &[u8]) -> Vec<(String, Value)> {
791        let text = std::str::from_utf8(bytes).unwrap();
792        let mut events = Vec::new();
793        let mut current_event = String::new();
794        for line in text.lines() {
795            if let Some(e) = line.strip_prefix("event: ") {
796                current_event = e.to_string();
797            } else if let Some(d) = line.strip_prefix("data: ") {
798                events.push((current_event.clone(), serde_json::from_str(d).unwrap()));
799            }
800        }
801        events
802    }
803
804    #[test]
805    fn stream_translates_text_then_tool_call() {
806        let mut xlat = StreamXlat::default();
807        let mut out = Vec::new();
808        for line in [
809            r#"data: {"id":"c1","model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"}}]}"#,
810            r#"data: {"id":"c1","choices":[{"index":0,"delta":{"content":"lo"}}]}"#,
811            r#"data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}"#,
812            r#"data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"ZRH\"}"}}]}}]}"#,
813            r#"data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#,
814            r#"data: {"id":"c1","choices":[],"usage":{"prompt_tokens":50,"completion_tokens":12}}"#,
815            "data: [DONE]",
816        ] {
817            out.extend(xlat.feed(line.as_bytes()));
818            out.extend(xlat.feed(b"\n\n"));
819        }
820
821        let events = parse_events(&out);
822        let names: Vec<&str> = events.iter().map(|(e, _)| e.as_str()).collect();
823        assert_eq!(
824            names,
825            vec![
826                "message_start",
827                "content_block_start", // text
828                "content_block_delta",
829                "content_block_delta",
830                "content_block_stop",
831                "content_block_start", // tool_use
832                "content_block_delta",
833                "content_block_stop",
834                "message_delta",
835                "message_stop",
836            ]
837        );
838
839        assert_eq!(events[0].1["message"]["model"], "gpt-4o-mini");
840        assert_eq!(events[2].1["delta"]["text"], "Hel");
841        assert_eq!(events[3].1["delta"]["text"], "lo");
842        let tool_start = &events[5].1;
843        assert_eq!(tool_start["content_block"]["type"], "tool_use");
844        assert_eq!(tool_start["content_block"]["id"], "call_1");
845        assert_eq!(tool_start["content_block"]["name"], "get_weather");
846        assert_eq!(tool_start["index"], 1);
847        assert_eq!(events[6].1["delta"]["partial_json"], "{\"city\":\"ZRH\"}");
848        let delta = &events[8].1;
849        assert_eq!(delta["delta"]["stop_reason"], "tool_use");
850        assert_eq!(delta["usage"]["input_tokens"], 50);
851        assert_eq!(delta["usage"]["output_tokens"], 12);
852    }
853
854    #[test]
855    fn stream_survives_chunk_splits_mid_line() {
856        let full = concat!(
857            r#"data: {"id":"c2","model":"m","choices":[{"index":0,"delta":{"content":"split works"}}]}"#,
858            "\n\ndata: [DONE]\n\n"
859        );
860        let mut xlat = StreamXlat::default();
861        let mut out = Vec::new();
862        // Feed in pathological 7-byte slices.
863        for chunk in full.as_bytes().chunks(7) {
864            out.extend(xlat.feed(chunk));
865        }
866        let events = parse_events(&out);
867        assert_eq!(events.first().unwrap().0, "message_start");
868        assert!(
869            events
870                .iter()
871                .any(|(e, d)| e == "content_block_delta" && d["delta"]["text"] == "split works")
872        );
873        assert_eq!(events.last().unwrap().0, "message_stop");
874    }
875
876    #[test]
877    fn stream_without_done_is_closed_by_finish() {
878        let mut xlat = StreamXlat::default();
879        let mut out = xlat.feed(
880            b"data: {\"id\":\"c3\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n",
881        );
882        out.extend(xlat.finish());
883        out.extend(xlat.finish()); // idempotent
884        let events = parse_events(&out);
885        assert_eq!(events.last().unwrap().0, "message_stop");
886        let delta = events.iter().find(|(e, _)| e == "message_delta").unwrap();
887        assert_eq!(delta.1["delta"]["stop_reason"], "end_turn");
888    }
889
890    #[test]
891    fn stream_adapter_translates_and_flushes() {
892        let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> = vec![
893            Ok(br#"data: {"id":"c4","model":"m","choices":[{"index":0,"delta":{"content":"ok"}}]}"#.to_vec()),
894            Ok(b"\n\n".to_vec()),
895        ];
896        let inner = futures::stream::iter(chunks);
897        let translated = to_anthropic_stream(Box::pin(inner));
898        let collected: Vec<_> =
899            futures::executor::block_on(futures::StreamExt::collect::<Vec<_>>(translated));
900        let bytes: Vec<u8> = collected.into_iter().flat_map(Result::unwrap).collect();
901        let events = parse_events(&bytes);
902        assert_eq!(events.first().unwrap().0, "message_start");
903        assert_eq!(events.last().unwrap().0, "message_stop");
904    }
905}