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::{Map, Value, json};
7
8use crate::LlmResponseChunk;
9use crate::codecs::stream::{
10    StreamCodec, StreamTranslationState, record_source_identity,
11    target_message_id_or_source_message_id, target_model_or_source_model,
12};
13use crate::format::{FormatId, WireFormat};
14use crate::util::{desanitize_anthropic_tool_use_id, 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 is_terminal_event(&self, event: &Value) -> bool {
25        event.get("type").and_then(Value::as_str) == Some("message_stop")
26    }
27
28    fn stops_at_terminal_event(&self) -> bool {
29        true
30    }
31
32    fn sse_done_is_terminal(&self) -> bool {
33        false
34    }
35
36    fn stamp_streamed_response_model(&self, event: &mut Value, served_model: &str) {
37        if let Some(message) = event.get_mut("message").and_then(Value::as_object_mut) {
38            message.insert("model".to_string(), Value::String(served_model.to_string()));
39        }
40    }
41
42    fn decode_event(
43        &self,
44        state: &mut StreamTranslationState,
45        event: &Value,
46    ) -> Vec<LlmResponseChunk> {
47        decode_anthropic_stream(state, event)
48    }
49
50    fn encode_event(
51        &self,
52        state: &mut StreamTranslationState,
53        event: LlmResponseChunk,
54    ) -> Vec<Value> {
55        encode_anthropic_stream(state, event)
56    }
57
58    fn observe_replayed_event(
59        &self,
60        state: &mut StreamTranslationState,
61        raw: &Value,
62        normalized: Vec<LlmResponseChunk>,
63    ) {
64        let normalized_stop = normalized
65            .iter()
66            .any(|chunk| matches!(chunk, LlmResponseChunk::MessageStop { .. }));
67        for chunk in normalized {
68            drop(encode_anthropic_stream(state, chunk));
69        }
70
71        match raw.get("type").and_then(Value::as_str) {
72            // Anthropic carries the stop reason and usage in `message_delta`, but the separate
73            // `message_stop` event is what actually terminates the stream.
74            Some("message_delta") if normalized_stop => state.emitted_message_delta = true,
75            Some("message_stop") => state.finished = true,
76            _ => {}
77        }
78    }
79
80    fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
81        finish_anthropic_stream(state)
82    }
83}
84
85// Decodes one Anthropic Messages event into neutral streaming events.
86fn decode_anthropic_stream(
87    state: &mut StreamTranslationState,
88    event: &Value,
89) -> Vec<LlmResponseChunk> {
90    let Some(object) = event.as_object() else {
91        return vec![LlmResponseChunk::DecodeError {
92            message: "Anthropic stream event is not an object".to_string(),
93        }];
94    };
95    match object.get("type").and_then(Value::as_str) {
96        Some("message_start") => {
97            state.saw_message_start = true;
98            let message = object.get("message").and_then(Value::as_object);
99            if let Some(model) = message
100                .and_then(|message| message.get("model"))
101                .and_then(Value::as_str)
102            {
103                state.model = Some(model.to_string());
104            }
105            if let Some(id) = message
106                .and_then(|message| message.get("id"))
107                .and_then(Value::as_str)
108            {
109                state.message_id = Some(id.to_string());
110            }
111            if let Some(message) = message
112                && let Some(usage) = message.get("usage")
113            {
114                capture_anthropic_usage(state, usage);
115            }
116            vec![LlmResponseChunk::MessageStart {
117                id: state.message_id.clone(),
118                model: state.model.clone(),
119            }]
120        }
121        Some("content_block_start") => decode_anthropic_content_block_start(state, object),
122        Some("content_block_delta") => decode_anthropic_content_block_delta(state, object),
123        Some("content_block_stop") => {
124            let index = object.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
125            if state.empty_anthropic_tool_inputs.remove(&index) {
126                vec![LlmResponseChunk::ToolCallDelta {
127                    index,
128                    id: None,
129                    name: None,
130                    arguments_delta: Some("{}".to_string()),
131                }]
132            } else {
133                Vec::new()
134            }
135        }
136        Some("message_delta") => {
137            let mut out = Vec::new();
138            if let Some(usage) = object.get("usage") {
139                capture_anthropic_usage(state, usage);
140                out.push(LlmResponseChunk::Usage(state.usage.clone()));
141            }
142            if let Some(stop_reason) = object
143                .get("delta")
144                .and_then(Value::as_object)
145                .and_then(|delta| delta.get("stop_reason"))
146                .and_then(Value::as_str)
147            {
148                // Remember the provider stop reason: Anthropic delivers it here, on
149                // `message_delta`, while the terminal `message_stop` carries none of its own.
150                state.stop_reason = Some(stop_reason.to_string());
151                state.stop_details = object
152                    .get("delta")
153                    .and_then(Value::as_object)
154                    .and_then(|delta| delta.get("stop_details"))
155                    .filter(|details| !details.is_null())
156                    .cloned();
157                out.push(LlmResponseChunk::MessageStop {
158                    reason: Some(stop_reason.to_string()),
159                });
160            }
161            out
162        }
163        // Anthropic's terminal `message_stop` carries no reason of its own; replay the one
164        // remembered from `message_delta` so a chunk accumulator keeps the real stop reason
165        // (e.g. `max_tokens`) instead of overwriting it with a reasonless `EndTurn`.
166        Some("message_stop") => vec![LlmResponseChunk::MessageStop {
167            reason: state.stop_reason.clone(),
168        }],
169        Some("error") => vec![LlmResponseChunk::StreamError {
170            message: object
171                .get("error")
172                .and_then(Value::as_object)
173                .and_then(|error| error.get("message"))
174                .and_then(Value::as_str)
175                .unwrap_or("unknown Anthropic stream error")
176                .to_string(),
177        }],
178        _ => Vec::new(),
179    }
180}
181
182// Encodes neutral streaming events into Anthropic Messages events.
183fn encode_anthropic_stream(
184    state: &mut StreamTranslationState,
185    event: LlmResponseChunk,
186) -> Vec<Value> {
187    // An in-band error is terminal: once the error is emitted, drop every later chunk.
188    if state.errored {
189        return Vec::new();
190    }
191    match event {
192        LlmResponseChunk::MessageStart { id, model } => {
193            record_source_identity(state, id, model);
194            if state.emitted_message_start {
195                Vec::new()
196            } else {
197                state.emitted_message_start = true;
198                vec![json!({
199                    "type": "message_start",
200                    "message": {
201                        "id": anthropic_message_id(state),
202                        "type": "message",
203                        "role": "assistant",
204                        "model": target_model_or_source_model(state),
205                        "content": [],
206                        "stop_reason": Value::Null,
207                        "stop_sequence": Value::Null,
208                        "usage": {"input_tokens": 0, "output_tokens": 0},
209                    },
210                })]
211            }
212        }
213        LlmResponseChunk::TextDelta { text, .. } => {
214            state.output_tokens_seen += 1;
215            let mut out = ensure_anthropic_text_block(state);
216            out.push(json!({
217                "type": "content_block_delta",
218                "index": state.text_block_index.unwrap_or(0),
219                "delta": {"type": "text_delta", "text": text},
220            }));
221            out
222        }
223        LlmResponseChunk::ReasoningDelta { text, .. } => {
224            let mut out = ensure_anthropic_reasoning_block(state);
225            out.push(json!({
226                "type": "content_block_delta",
227                "index": state.reasoning_block_index.unwrap_or(0),
228                "delta": {"type": "thinking_delta", "thinking": text},
229            }));
230            out
231        }
232        LlmResponseChunk::ReasoningDetailsDelta { text, .. } => {
233            if text.is_empty() {
234                return Vec::new();
235            }
236            let mut out = ensure_anthropic_reasoning_block(state);
237            out.push(json!({
238                "type": "content_block_delta",
239                "index": state.reasoning_block_index.unwrap_or(0),
240                "delta": {"type": "thinking_delta", "thinking": text},
241            }));
242            out
243        }
244        LlmResponseChunk::ToolCallDelta {
245            index,
246            id,
247            name,
248            arguments_delta,
249        } => encode_anthropic_tool_delta(state, index, id, name, arguments_delta),
250        LlmResponseChunk::Usage(usage) => {
251            state.usage = usage;
252            state.saw_backend_usage = true;
253            Vec::new()
254        }
255        LlmResponseChunk::MessageStop { reason } => {
256            state.stop_reason = reason.or_else(|| state.stop_reason.clone());
257            Vec::new()
258        }
259        LlmResponseChunk::StreamError { message } | LlmResponseChunk::DecodeError { message } => {
260            // An in-band error is terminal: emit the error, then nothing further.
261            state.finished = true; // finish() adds no success events
262            state.errored = true; // the entry guard drops any later chunk
263            vec![json!({"type": "error", "error": {"type": "api_error", "message": message}})]
264        }
265    }
266}
267
268// Emits any missing Anthropic terminal events and closes open content blocks.
269fn finish_anthropic_stream(state: &mut StreamTranslationState) -> Vec<Value> {
270    if state.finished {
271        return Vec::new();
272    }
273    if state.tool_states.values().any(|tool| {
274        tool.id.as_deref().is_none_or(str::is_empty)
275            || tool.name.as_deref().is_none_or(str::is_empty)
276    }) {
277        return encode_anthropic_stream(
278            state,
279            LlmResponseChunk::StreamError {
280                message: "Tool call ended without a non-empty ID and name".to_string(),
281            },
282        );
283    }
284    let mut out = Vec::new();
285    if !state.emitted_message_start {
286        out.extend(encode_anthropic_stream(
287            state,
288            LlmResponseChunk::MessageStart {
289                id: state.message_id.clone(),
290                model: state.model.clone(),
291            },
292        ));
293    }
294
295    if state.text_block_started {
296        if let Some(index) = state.text_block_index {
297            out.push(json!({"type": "content_block_stop", "index": index}));
298        }
299        state.text_block_started = false;
300    }
301
302    out.extend(close_anthropic_reasoning_block(state));
303
304    for tool in state.tool_states.values_mut() {
305        if tool.started {
306            if let Some(index) = tool.content_index {
307                out.push(json!({"type": "content_block_stop", "index": index}));
308            }
309            tool.started = false;
310        }
311    }
312
313    state.active_anthropic_tool = None;
314    for index in std::mem::take(&mut state.deferred_anthropic_tools) {
315        out.extend(encode_anthropic_tool_delta(state, index, None, None, None));
316        if let Some(tool) = state.tool_states.get_mut(&index) {
317            if let Some(content_index) = tool.content_index {
318                out.push(json!({"type": "content_block_stop", "index": content_index}));
319            }
320            tool.started = false;
321        }
322        state.active_anthropic_tool = None;
323    }
324
325    if !state.emitted_content_block {
326        out.push(json!({
327            "type": "content_block_start",
328            "index": 0,
329            "content_block": {"type": "text", "text": ""},
330        }));
331        out.push(json!({"type": "content_block_stop", "index": 0}));
332    }
333
334    if !state.emitted_message_delta {
335        out.push(json!({
336            "type": "message_delta",
337            "delta": {
338                "stop_reason": anthropic_stop_reason(state.stop_reason.as_deref()),
339                "stop_sequence": Value::Null,
340                "stop_details": anthropic_stop_details(
341                    state.stop_reason.as_deref(),
342                    state.stop_details.as_ref(),
343                ),
344            },
345            "usage": anthropic_stream_usage(state),
346        }));
347        state.emitted_message_delta = true;
348    }
349    out.push(json!({"type": "message_stop"}));
350    state.finished = true;
351    out
352}
353
354// Converts Anthropic content-block starts into text or tool-call deltas.
355fn decode_anthropic_content_block_start(
356    state: &mut StreamTranslationState,
357    object: &Map<String, Value>,
358) -> Vec<LlmResponseChunk> {
359    let index = object.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
360    let block = object.get("content_block").and_then(Value::as_object);
361    match block
362        .and_then(|block| block.get("type"))
363        .and_then(Value::as_str)
364    {
365        Some("text") => block
366            .and_then(|block| block.get("text"))
367            .and_then(Value::as_str)
368            .filter(|text| !text.is_empty())
369            .map(|text| {
370                vec![LlmResponseChunk::TextDelta {
371                    index,
372                    text: text.to_string(),
373                }]
374            })
375            .unwrap_or_default(),
376        Some("thinking") => {
377            let mut out = Vec::new();
378            if let Some(text) = block
379                .and_then(|block| block.get("thinking"))
380                .and_then(Value::as_str)
381                .filter(|text| !text.is_empty())
382            {
383                out.push(LlmResponseChunk::ReasoningDelta {
384                    index,
385                    text: text.to_string(),
386                });
387            }
388            if let Some(signature) = block
389                .and_then(|block| block.get("signature"))
390                .and_then(Value::as_str)
391                .filter(|signature| !signature.is_empty())
392            {
393                out.push(anthropic_signature_delta(index, signature));
394            }
395            out
396        }
397        Some("tool_use") => {
398            let Some(block) = block else {
399                return Vec::new();
400            };
401            if block
402                .get("input")
403                .and_then(Value::as_object)
404                .is_some_and(|input| input.is_empty())
405            {
406                state.empty_anthropic_tool_inputs.insert(index);
407            }
408            vec![LlmResponseChunk::ToolCallDelta {
409                index,
410                id: block
411                    .get("id")
412                    .and_then(Value::as_str)
413                    .map(desanitize_anthropic_tool_use_id),
414                name: block
415                    .get("name")
416                    .and_then(Value::as_str)
417                    .map(ToOwned::to_owned),
418                arguments_delta: block.get("input").and_then(tool_input_delta),
419            }]
420        }
421        _ => Vec::new(),
422    }
423}
424
425// Converts Anthropic content-block deltas into neutral text or argument deltas.
426fn decode_anthropic_content_block_delta(
427    state: &mut StreamTranslationState,
428    object: &Map<String, Value>,
429) -> Vec<LlmResponseChunk> {
430    let index = object.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
431    let Some(delta) = object.get("delta").and_then(Value::as_object) else {
432        return Vec::new();
433    };
434    match delta.get("type").and_then(Value::as_str) {
435        Some("text_delta") => delta
436            .get("text")
437            .and_then(Value::as_str)
438            .map(|text| {
439                vec![LlmResponseChunk::TextDelta {
440                    index,
441                    text: text.to_string(),
442                }]
443            })
444            .unwrap_or_default(),
445        Some("thinking_delta") => delta
446            .get("thinking")
447            .and_then(Value::as_str)
448            .map(|text| {
449                vec![LlmResponseChunk::ReasoningDelta {
450                    index,
451                    text: text.to_string(),
452                }]
453            })
454            .unwrap_or_default(),
455        Some("signature_delta") => delta
456            .get("signature")
457            .and_then(Value::as_str)
458            .map(|signature| vec![anthropic_signature_delta(index, signature)])
459            .unwrap_or_default(),
460        Some("input_json_delta") => delta
461            .get("partial_json")
462            .and_then(Value::as_str)
463            .map(|partial_json| {
464                if !partial_json.is_empty() {
465                    state.empty_anthropic_tool_inputs.remove(&index);
466                }
467                vec![LlmResponseChunk::ToolCallDelta {
468                    index,
469                    id: None,
470                    name: None,
471                    arguments_delta: Some(partial_json.to_string()),
472                }]
473            })
474            .unwrap_or_default(),
475        _ => Vec::new(),
476    }
477}
478
479fn anthropic_signature_delta(index: usize, signature: &str) -> LlmResponseChunk {
480    LlmResponseChunk::ReasoningDetailsDelta {
481        index,
482        details: vec![json!({"type": "anthropic.signature_delta", "signature": signature})],
483        text: String::new(),
484    }
485}
486
487// Starts an Anthropic text block when the target stream needs one.
488fn ensure_anthropic_text_block(state: &mut StreamTranslationState) -> Vec<Value> {
489    let mut out = close_anthropic_reasoning_block(state);
490    if state.text_block_started {
491        return out;
492    }
493    let index = state.next_content_index;
494    state.next_content_index += 1;
495    state.text_block_index = Some(index);
496    state.text_block_started = true;
497    state.emitted_content_block = true;
498    out.push(json!({
499        "type": "content_block_start",
500        "index": index,
501        "content_block": {"type": "text", "text": ""},
502    }));
503    out
504}
505
506// Starts an Anthropic thinking block when the target stream needs one.
507fn ensure_anthropic_reasoning_block(state: &mut StreamTranslationState) -> Vec<Value> {
508    let mut out = Vec::new();
509    if state.text_block_started {
510        if let Some(index) = state.text_block_index {
511            out.push(json!({"type": "content_block_stop", "index": index}));
512        }
513        state.text_block_started = false;
514    }
515    if state.reasoning_block_started {
516        return out;
517    }
518    let index = state.next_content_index;
519    state.next_content_index += 1;
520    state.reasoning_block_index = Some(index);
521    state.reasoning_block_started = true;
522    state.emitted_content_block = true;
523    out.push(json!({
524        "type": "content_block_start",
525        "index": index,
526        "content_block": {"type": "thinking", "thinking": "", "signature": ""},
527    }));
528    out
529}
530
531// Closes an open Anthropic thinking block before text/tool content starts.
532fn close_anthropic_reasoning_block(state: &mut StreamTranslationState) -> Vec<Value> {
533    if !state.reasoning_block_started {
534        return Vec::new();
535    }
536    let mut out = Vec::new();
537    if let Some(index) = state.reasoning_block_index {
538        out.push(json!({
539            "type": "content_block_delta",
540            "index": index,
541            "delta": {"type": "signature_delta", "signature": ""},
542        }));
543        out.push(json!({"type": "content_block_stop", "index": index}));
544    }
545    state.reasoning_block_started = false;
546    out
547}
548
549// Accumulates tool-call state and emits Anthropic tool-use deltas.
550fn encode_anthropic_tool_delta(
551    state: &mut StreamTranslationState,
552    index: usize,
553    id: Option<String>,
554    name: Option<String>,
555    arguments_delta: Option<String>,
556) -> Vec<Value> {
557    let mut out = Vec::new();
558    out.extend(close_anthropic_reasoning_block(state));
559    if state.text_block_started {
560        if let Some(index) = state.text_block_index {
561            out.push(json!({"type": "content_block_stop", "index": index}));
562        }
563        state.text_block_started = false;
564    }
565
566    // Reserve the first call even while its name is still being assembled.
567    let other_tool_started = *state.active_anthropic_tool.get_or_insert(index) != index;
568    let tool = state.tool_states.entry(index).or_default();
569    if let Some(id) = id.filter(|id| !id.is_empty()) {
570        tool.id = Some(sanitize_anthropic_tool_use_id(&id));
571    }
572    if let Some(name) = name.filter(|name| !name.is_empty()) {
573        tool.name = Some(name);
574    }
575    if let Some(delta) = arguments_delta {
576        tool.arguments.push_str(&delta);
577        tool.pending_arguments.push_str(&delta);
578    }
579
580    // Chat can interleave calls until EOF; Anthropic callbacks require one open block.
581    if other_tool_started {
582        if !state.deferred_anthropic_tools.contains(&index) {
583            state.deferred_anthropic_tools.push(index);
584        }
585        return out;
586    }
587
588    if !tool.started {
589        // A published tool-use ID cannot be replaced when a later snapshot supplies it.
590        let (Some(id), Some(name)) = (
591            tool.id.as_deref().filter(|id| !id.is_empty()),
592            tool.name.as_deref().filter(|name| !name.is_empty()),
593        ) else {
594            return out;
595        };
596        let content_index = state.next_content_index;
597        state.next_content_index += 1;
598        tool.content_index = Some(content_index);
599        tool.started = true;
600        state.emitted_content_block = true;
601        out.push(json!({
602            "type": "content_block_start",
603            "index": content_index,
604            "content_block": {
605                "type": "tool_use",
606                "id": id,
607                "name": name,
608                "input": {},
609            },
610        }));
611        if !tool.pending_arguments.is_empty() {
612            out.push(json!({
613                "type": "content_block_delta",
614                "index": content_index,
615                "delta": {
616                    "type": "input_json_delta",
617                    "partial_json": tool.pending_arguments,
618                },
619            }));
620            tool.pending_arguments.clear();
621        }
622        return out;
623    }
624
625    if let Some(content_index) = tool.content_index
626        && !tool.pending_arguments.is_empty()
627    {
628        out.push(json!({
629            "type": "content_block_delta",
630            "index": content_index,
631            "delta": {
632                "type": "input_json_delta",
633                "partial_json": tool.pending_arguments,
634            },
635        }));
636        tool.pending_arguments.clear();
637    }
638    out
639}
640
641// Preserves Anthropic usage fields and updates normalized token counts.
642fn capture_anthropic_usage(state: &mut StreamTranslationState, usage: &Value) {
643    let Some(usage) = usage.as_object() else {
644        return;
645    };
646    if let Some(value) = usage.get("input_tokens").and_then(Value::as_u64) {
647        state.usage.input_tokens = Some(value);
648    }
649    if let Some(cost) = crate::codecs::common::provider_cost(usage) {
650        state.usage.provider_cost = Some(cost);
651    }
652    if let Some(tokens) = usage
653        .get("cache_creation")
654        .and_then(|details| details.get("ephemeral_1h_input_tokens"))
655        .and_then(Value::as_u64)
656    {
657        state
658            .usage
659            .cache
660            .get_or_insert_with(Default::default)
661            .cache_creation_1h_input_tokens = Some(tokens);
662    }
663    if let Some(value) = usage.get("output_tokens").and_then(Value::as_u64) {
664        state.usage.output_tokens = Some(value);
665    }
666    if let Some(value) = usage
667        .get("cache_creation_input_tokens")
668        .and_then(Value::as_u64)
669    {
670        state.usage.set_cache_creation_input_tokens(value);
671    }
672    if let Some(value) = usage.get("cache_read_input_tokens").and_then(Value::as_u64) {
673        state.usage.set_cached_input_tokens(value);
674    }
675    if let Some(value) = usage.get("output_tokens_details").and_then(|details| {
676        details
677            .get("thinking_tokens")
678            .and_then(Value::as_u64)
679            .or_else(|| details.get("reasoning_tokens").and_then(Value::as_u64))
680    }) {
681        state.usage.reasoning_tokens = Some(value);
682    }
683}
684
685// Builds Anthropic usage payloads from normalized and provider-extra state.
686fn anthropic_stream_usage(state: &StreamTranslationState) -> Value {
687    let mut usage = Map::new();
688    if state.saw_backend_usage {
689        if let Some(input_tokens) = state.usage.input_tokens {
690            usage.insert("input_tokens".to_string(), json!(input_tokens));
691        }
692        usage.insert(
693            "output_tokens".to_string(),
694            json!(state.usage.output_tokens.unwrap_or(0)),
695        );
696    } else {
697        usage.insert("output_tokens".to_string(), json!(state.output_tokens_seen));
698    }
699    if let Some(value) = state.usage.cache_creation_input_tokens() {
700        usage.insert("cache_creation_input_tokens".to_string(), json!(value));
701    }
702    if let Some(value) = state.usage.cached_input_tokens() {
703        usage.insert("cache_read_input_tokens".to_string(), json!(value));
704    }
705    if let Some(value) = state.usage.reasoning_tokens {
706        usage.insert(
707            "output_tokens_details".to_string(),
708            json!({"thinking_tokens": value}),
709        );
710    }
711    crate::codecs::common::encode_usage_metadata(Value::Object(usage), &state.usage)
712}
713
714// Converts any upstream message ID into an Anthropic-looking message ID.
715fn anthropic_message_id(state: &StreamTranslationState) -> String {
716    let Some(id) = target_message_id_or_source_message_id(state) else {
717        return "msg_switchyard".to_string();
718    };
719    if id.starts_with("msg_") {
720        id.to_string()
721    } else {
722        format!("msg_{id}")
723    }
724}
725
726// Maps provider stop reasons into Anthropic's stop-reason vocabulary.
727fn anthropic_stop_reason(reason: Option<&str>) -> String {
728    match reason {
729        Some("length") => "max_tokens".to_string(),
730        Some("tool_calls") | Some("function_call") => "tool_use".to_string(),
731        Some("content_filter" | "refusal") => "refusal".to_string(),
732        Some(reason @ ("end_turn" | "max_tokens" | "tool_use" | "stop_sequence")) => {
733            reason.to_string()
734        }
735        _ => "end_turn".to_string(),
736    }
737}
738
739// Emits the metadata object Anthropic pairs with a `refusal` stop reason.
740//
741// A source that already reported `stop_details` carries the named policy category and
742// its explanation, so replay that object verbatim. Only a refusal synthesized from a
743// provider that reports no category falls back to the null form, which Anthropic
744// documents as the normal value for a refusal that maps to no named category.
745fn anthropic_stop_details(reason: Option<&str>, source: Option<&Value>) -> Value {
746    match reason {
747        Some("content_filter" | "refusal") => source.cloned().unwrap_or_else(|| {
748            json!({
749                "type": "refusal",
750                "category": Value::Null,
751                "explanation": Value::Null,
752            })
753        }),
754        _ => Value::Null,
755    }
756}
757
758// Converts a streamed tool input fragment into a string delta.
759fn tool_input_delta(value: &Value) -> Option<String> {
760    match value {
761        Value::String(text) if !text.is_empty() => Some(text.clone()),
762        Value::Object(object) if !object.is_empty() => serde_json::to_string(value).ok(),
763        _ => None,
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn recognizes_stream_completion() {
773        let codec = AnthropicMessagesStreamCodec;
774        assert!(!codec.sse_done_is_terminal());
775        assert!(codec.is_terminal_event(&json!({"type": "message_stop"})));
776        assert!(!codec.is_terminal_event(&json!({"type": "message_delta"})));
777    }
778
779    #[test]
780    fn anthropic_replay_stamps_the_served_model_inside_message() {
781        let mut event = json!({
782            "type": "message_start",
783            "message": {
784                "id": "msg_1",
785                "model": "provider/model",
786                "provider_extension": true,
787            },
788        });
789
790        AnthropicMessagesStreamCodec.stamp_streamed_response_model(&mut event, "served/model");
791
792        assert_eq!(event["message"]["model"], "served/model");
793        assert_eq!(event["message"]["provider_extension"], true);
794    }
795}