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