Skip to main content

switchyard_translation/codecs/anthropic/
stream.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Streaming codec for Anthropic Messages events.
5
6use serde_json::{json, Map, Value};
7
8use crate::codecs::stream::{
9    record_source_identity, target_message_id_or_source_message_id, target_model_or_source_model,
10    StreamCodec, StreamTranslationState,
11};
12use crate::format::{FormatId, WireFormat};
13use crate::llm::LlmStreamEvent;
14use crate::util::sanitize_anthropic_tool_use_id;
15
16/// Stream codec for Anthropic Messages events.
17pub struct AnthropicMessagesStreamCodec;
18
19impl StreamCodec for AnthropicMessagesStreamCodec {
20    fn format(&self) -> FormatId {
21        WireFormat::AnthropicMessages.into()
22    }
23
24    fn decode_event(
25        &self,
26        state: &mut StreamTranslationState,
27        event: &Value,
28    ) -> Vec<LlmStreamEvent> {
29        decode_anthropic_stream(state, event)
30    }
31
32    fn encode_event(
33        &self,
34        state: &mut StreamTranslationState,
35        event: LlmStreamEvent,
36    ) -> Vec<Value> {
37        encode_anthropic_stream(state, event)
38    }
39
40    fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
41        finish_anthropic_stream(state)
42    }
43}
44
45// Decodes one Anthropic Messages event into neutral streaming events.
46fn decode_anthropic_stream(
47    state: &mut StreamTranslationState,
48    event: &Value,
49) -> Vec<LlmStreamEvent> {
50    let Some(object) = event.as_object() else {
51        return vec![LlmStreamEvent::Error {
52            message: "Anthropic stream event is not an object".to_string(),
53        }];
54    };
55    match object.get("type").and_then(Value::as_str) {
56        Some("message_start") => {
57            state.saw_message_start = true;
58            let message = object.get("message").and_then(Value::as_object);
59            if let Some(model) = message
60                .and_then(|message| message.get("model"))
61                .and_then(Value::as_str)
62            {
63                state.model = Some(model.to_string());
64            }
65            if let Some(id) = message
66                .and_then(|message| message.get("id"))
67                .and_then(Value::as_str)
68            {
69                state.message_id = Some(id.to_string());
70            }
71            if let Some(message) = message {
72                if let Some(usage) = message.get("usage") {
73                    capture_anthropic_usage(state, usage);
74                }
75            }
76            vec![LlmStreamEvent::MessageStart {
77                id: state.message_id.clone(),
78                model: state.model.clone(),
79            }]
80        }
81        Some("content_block_start") => decode_anthropic_content_block_start(object),
82        Some("content_block_delta") => decode_anthropic_content_block_delta(object),
83        Some("message_delta") => {
84            let mut out = Vec::new();
85            if let Some(usage) = object.get("usage") {
86                capture_anthropic_usage(state, usage);
87                out.push(LlmStreamEvent::Usage(state.usage.clone()));
88            }
89            if let Some(stop_reason) = object
90                .get("delta")
91                .and_then(Value::as_object)
92                .and_then(|delta| delta.get("stop_reason"))
93                .and_then(Value::as_str)
94            {
95                out.push(LlmStreamEvent::MessageStop {
96                    reason: Some(stop_reason.to_string()),
97                });
98            }
99            out
100        }
101        Some("message_stop") => vec![LlmStreamEvent::MessageStop { reason: None }],
102        Some("error") => vec![LlmStreamEvent::Error {
103            message: object
104                .get("error")
105                .and_then(Value::as_object)
106                .and_then(|error| error.get("message"))
107                .and_then(Value::as_str)
108                .unwrap_or("unknown Anthropic stream error")
109                .to_string(),
110        }],
111        _ => Vec::new(),
112    }
113}
114
115// Encodes neutral streaming events into Anthropic Messages events.
116fn encode_anthropic_stream(
117    state: &mut StreamTranslationState,
118    event: LlmStreamEvent,
119) -> Vec<Value> {
120    match event {
121        LlmStreamEvent::MessageStart { id, model } => {
122            record_source_identity(state, id, model);
123            if state.emitted_message_start {
124                Vec::new()
125            } else {
126                state.emitted_message_start = true;
127                vec![json!({
128                    "type": "message_start",
129                    "message": {
130                        "id": anthropic_message_id(state),
131                        "type": "message",
132                        "role": "assistant",
133                        "model": target_model_or_source_model(state),
134                        "content": [],
135                        "stop_reason": Value::Null,
136                        "stop_sequence": Value::Null,
137                        "usage": {"input_tokens": 0, "output_tokens": 0},
138                    },
139                })]
140            }
141        }
142        LlmStreamEvent::TextDelta { text, .. } => {
143            state.output_tokens_seen += 1;
144            let mut out = ensure_anthropic_text_block(state);
145            out.push(json!({
146                "type": "content_block_delta",
147                "index": state.text_block_index.unwrap_or(0),
148                "delta": {"type": "text_delta", "text": text},
149            }));
150            out
151        }
152        LlmStreamEvent::ReasoningDelta { text, .. } => {
153            let mut out = ensure_anthropic_reasoning_block(state);
154            out.push(json!({
155                "type": "content_block_delta",
156                "index": state.reasoning_block_index.unwrap_or(0),
157                "delta": {"type": "thinking_delta", "thinking": text},
158            }));
159            out
160        }
161        LlmStreamEvent::ToolCallDelta {
162            index,
163            id,
164            name,
165            arguments_delta,
166        } => encode_anthropic_tool_delta(state, index, id, name, arguments_delta),
167        LlmStreamEvent::Usage(usage) => {
168            state.usage = usage;
169            state.saw_backend_usage = true;
170            Vec::new()
171        }
172        LlmStreamEvent::MessageStop { reason } => {
173            state.stop_reason = reason.or_else(|| state.stop_reason.clone());
174            Vec::new()
175        }
176        LlmStreamEvent::Error { message } => {
177            vec![json!({"type": "error", "error": {"message": message}})]
178        }
179    }
180}
181
182// Emits any missing Anthropic terminal events and closes open content blocks.
183fn finish_anthropic_stream(state: &mut StreamTranslationState) -> Vec<Value> {
184    let mut out = Vec::new();
185    if !state.emitted_message_start {
186        out.extend(encode_anthropic_stream(
187            state,
188            LlmStreamEvent::MessageStart {
189                id: state.message_id.clone(),
190                model: state.model.clone(),
191            },
192        ));
193    }
194
195    if state.text_block_started {
196        if let Some(index) = state.text_block_index {
197            out.push(json!({"type": "content_block_stop", "index": index}));
198        }
199        state.text_block_started = false;
200    }
201
202    out.extend(close_anthropic_reasoning_block(state));
203
204    for tool in state.tool_states.values_mut() {
205        if tool.started {
206            if let Some(index) = tool.content_index {
207                out.push(json!({"type": "content_block_stop", "index": index}));
208            }
209            tool.started = false;
210        }
211    }
212
213    if !state.emitted_content_block {
214        out.push(json!({
215            "type": "content_block_start",
216            "index": 0,
217            "content_block": {"type": "text", "text": ""},
218        }));
219        out.push(json!({"type": "content_block_stop", "index": 0}));
220    }
221
222    out.push(json!({
223        "type": "message_delta",
224        "delta": {
225            "stop_reason": anthropic_stop_reason(state.stop_reason.as_deref()),
226            "stop_sequence": Value::Null,
227        },
228        "usage": anthropic_stream_usage(state),
229    }));
230    out.push(json!({"type": "message_stop"}));
231    state.finished = true;
232    out
233}
234
235// Converts Anthropic content-block starts into text or tool-call deltas.
236fn decode_anthropic_content_block_start(object: &Map<String, Value>) -> Vec<LlmStreamEvent> {
237    let index = object.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
238    let block = object.get("content_block").and_then(Value::as_object);
239    match block
240        .and_then(|block| block.get("type"))
241        .and_then(Value::as_str)
242    {
243        Some("text") => block
244            .and_then(|block| block.get("text"))
245            .and_then(Value::as_str)
246            .filter(|text| !text.is_empty())
247            .map(|text| {
248                vec![LlmStreamEvent::TextDelta {
249                    index,
250                    text: text.to_string(),
251                }]
252            })
253            .unwrap_or_default(),
254        Some("thinking") => block
255            .and_then(|block| block.get("thinking"))
256            .and_then(Value::as_str)
257            .filter(|text| !text.is_empty())
258            .map(|text| {
259                vec![LlmStreamEvent::ReasoningDelta {
260                    index,
261                    text: text.to_string(),
262                }]
263            })
264            .unwrap_or_default(),
265        Some("tool_use") => {
266            let Some(block) = block else {
267                return Vec::new();
268            };
269            vec![LlmStreamEvent::ToolCallDelta {
270                index,
271                id: block
272                    .get("id")
273                    .and_then(Value::as_str)
274                    .map(ToOwned::to_owned),
275                name: block
276                    .get("name")
277                    .and_then(Value::as_str)
278                    .map(ToOwned::to_owned),
279                arguments_delta: block.get("input").and_then(tool_input_delta),
280            }]
281        }
282        _ => Vec::new(),
283    }
284}
285
286// Converts Anthropic content-block deltas into neutral text or argument deltas.
287fn decode_anthropic_content_block_delta(object: &Map<String, Value>) -> Vec<LlmStreamEvent> {
288    let index = object.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
289    let Some(delta) = object.get("delta").and_then(Value::as_object) else {
290        return Vec::new();
291    };
292    match delta.get("type").and_then(Value::as_str) {
293        Some("text_delta") => delta
294            .get("text")
295            .and_then(Value::as_str)
296            .map(|text| {
297                vec![LlmStreamEvent::TextDelta {
298                    index,
299                    text: text.to_string(),
300                }]
301            })
302            .unwrap_or_default(),
303        Some("thinking_delta") => delta
304            .get("thinking")
305            .and_then(Value::as_str)
306            .map(|text| {
307                vec![LlmStreamEvent::ReasoningDelta {
308                    index,
309                    text: text.to_string(),
310                }]
311            })
312            .unwrap_or_default(),
313        Some("signature_delta") => Vec::new(),
314        Some("input_json_delta") => delta
315            .get("partial_json")
316            .and_then(Value::as_str)
317            .map(|partial_json| {
318                vec![LlmStreamEvent::ToolCallDelta {
319                    index,
320                    id: None,
321                    name: None,
322                    arguments_delta: Some(partial_json.to_string()),
323                }]
324            })
325            .unwrap_or_default(),
326        _ => Vec::new(),
327    }
328}
329
330// Starts an Anthropic text block when the target stream needs one.
331fn ensure_anthropic_text_block(state: &mut StreamTranslationState) -> Vec<Value> {
332    let mut out = close_anthropic_reasoning_block(state);
333    if state.text_block_started {
334        return out;
335    }
336    let index = state.next_content_index;
337    state.next_content_index += 1;
338    state.text_block_index = Some(index);
339    state.text_block_started = true;
340    state.emitted_content_block = true;
341    out.push(json!({
342        "type": "content_block_start",
343        "index": index,
344        "content_block": {"type": "text", "text": ""},
345    }));
346    out
347}
348
349// Starts an Anthropic thinking block when the target stream needs one.
350fn ensure_anthropic_reasoning_block(state: &mut StreamTranslationState) -> Vec<Value> {
351    let mut out = Vec::new();
352    if state.text_block_started {
353        if let Some(index) = state.text_block_index {
354            out.push(json!({"type": "content_block_stop", "index": index}));
355        }
356        state.text_block_started = false;
357    }
358    if state.reasoning_block_started {
359        return out;
360    }
361    let index = state.next_content_index;
362    state.next_content_index += 1;
363    state.reasoning_block_index = Some(index);
364    state.reasoning_block_started = true;
365    state.emitted_content_block = true;
366    out.push(json!({
367        "type": "content_block_start",
368        "index": index,
369        "content_block": {"type": "thinking", "thinking": "", "signature": ""},
370    }));
371    out
372}
373
374// Closes an open Anthropic thinking block before text/tool content starts.
375fn close_anthropic_reasoning_block(state: &mut StreamTranslationState) -> Vec<Value> {
376    if !state.reasoning_block_started {
377        return Vec::new();
378    }
379    let mut out = Vec::new();
380    if let Some(index) = state.reasoning_block_index {
381        out.push(json!({
382            "type": "content_block_delta",
383            "index": index,
384            "delta": {"type": "signature_delta", "signature": ""},
385        }));
386        out.push(json!({"type": "content_block_stop", "index": index}));
387    }
388    state.reasoning_block_started = false;
389    out
390}
391
392// Accumulates tool-call state and emits Anthropic tool-use deltas.
393fn encode_anthropic_tool_delta(
394    state: &mut StreamTranslationState,
395    index: usize,
396    id: Option<String>,
397    name: Option<String>,
398    arguments_delta: Option<String>,
399) -> Vec<Value> {
400    let mut out = Vec::new();
401    out.extend(close_anthropic_reasoning_block(state));
402    if state.text_block_started {
403        if let Some(index) = state.text_block_index {
404            out.push(json!({"type": "content_block_stop", "index": index}));
405        }
406        state.text_block_started = false;
407    }
408
409    let tool = state.tool_states.entry(index).or_default();
410    if id.is_some() {
411        tool.id = id.map(|id| sanitize_anthropic_tool_use_id(&id));
412    }
413    if name.is_some() {
414        tool.name = name;
415    }
416    if let Some(delta) = arguments_delta {
417        tool.arguments.push_str(&delta);
418        tool.pending_arguments.push_str(&delta);
419    }
420
421    if !tool.started {
422        let Some(name) = tool.name.clone() else {
423            return out;
424        };
425        let content_index = state.next_content_index;
426        state.next_content_index += 1;
427        tool.content_index = Some(content_index);
428        tool.started = true;
429        state.emitted_content_block = true;
430        out.push(json!({
431            "type": "content_block_start",
432            "index": content_index,
433            "content_block": {
434                "type": "tool_use",
435                "id": tool.id.clone().unwrap_or_else(|| format!("toolu_{index}")),
436                "name": name,
437                "input": {},
438            },
439        }));
440        if !tool.pending_arguments.is_empty() {
441            out.push(json!({
442                "type": "content_block_delta",
443                "index": content_index,
444                "delta": {
445                    "type": "input_json_delta",
446                    "partial_json": tool.pending_arguments,
447                },
448            }));
449            tool.pending_arguments.clear();
450        }
451        return out;
452    }
453
454    if let Some(content_index) = tool.content_index {
455        if !tool.pending_arguments.is_empty() {
456            out.push(json!({
457                "type": "content_block_delta",
458                "index": content_index,
459                "delta": {
460                    "type": "input_json_delta",
461                    "partial_json": tool.pending_arguments,
462                },
463            }));
464            tool.pending_arguments.clear();
465        }
466    }
467    out
468}
469
470// Preserves Anthropic usage fields and updates normalized token counts.
471fn capture_anthropic_usage(state: &mut StreamTranslationState, usage: &Value) {
472    let Some(usage) = usage.as_object() else {
473        return;
474    };
475    for key in [
476        "input_tokens",
477        "output_tokens",
478        "cache_creation_input_tokens",
479        "cache_read_input_tokens",
480    ] {
481        if let Some(value) = usage.get(key).and_then(Value::as_u64) {
482            state.usage_extras.insert(key.to_string(), value);
483        }
484    }
485    state.usage.input_tokens = state.usage_extras.get("input_tokens").copied();
486    state.usage.output_tokens = state.usage_extras.get("output_tokens").copied();
487}
488
489// Builds Anthropic usage payloads from normalized and provider-extra state.
490fn anthropic_stream_usage(state: &StreamTranslationState) -> Value {
491    let mut usage = Map::new();
492    if state.saw_backend_usage {
493        if let Some(input_tokens) = state.usage.input_tokens {
494            usage.insert("input_tokens".to_string(), json!(input_tokens));
495        }
496        usage.insert(
497            "output_tokens".to_string(),
498            json!(state.usage.output_tokens.unwrap_or(0)),
499        );
500    } else {
501        usage.insert("output_tokens".to_string(), json!(state.output_tokens_seen));
502    }
503    for (key, value) in &state.usage_extras {
504        if key == "input_tokens" || key == "output_tokens" {
505            continue;
506        }
507        usage.insert(key.clone(), json!(value));
508    }
509    Value::Object(usage)
510}
511
512// Converts any upstream message ID into an Anthropic-looking message ID.
513fn anthropic_message_id(state: &StreamTranslationState) -> String {
514    let Some(id) = target_message_id_or_source_message_id(state) else {
515        return "msg_switchyard".to_string();
516    };
517    if id.starts_with("msg_") {
518        id.to_string()
519    } else {
520        format!("msg_{id}")
521    }
522}
523
524// Maps provider stop reasons into Anthropic's stop-reason vocabulary.
525fn anthropic_stop_reason(reason: Option<&str>) -> String {
526    match reason {
527        Some("length") => "max_tokens".to_string(),
528        Some("tool_calls") | Some("function_call") => "tool_use".to_string(),
529        Some("end_turn") | Some("max_tokens") | Some("tool_use") | Some("stop_sequence") => {
530            reason.unwrap_or("end_turn").to_string()
531        }
532        _ => "end_turn".to_string(),
533    }
534}
535
536// Converts a streamed tool input fragment into a string delta.
537fn tool_input_delta(value: &Value) -> Option<String> {
538    match value {
539        Value::String(text) if !text.is_empty() => Some(text.clone()),
540        Value::Object(object) if !object.is_empty() => serde_json::to_string(value).ok(),
541        _ => None,
542    }
543}