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