1use 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
16pub 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 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
85fn 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 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 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
182fn encode_anthropic_stream(
184 state: &mut StreamTranslationState,
185 event: LlmResponseChunk,
186) -> Vec<Value> {
187 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 state.finished = true; state.errored = true; vec![json!({"type": "error", "error": {"type": "api_error", "message": message}})]
264 }
265 }
266}
267
268fn 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
354fn 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
425fn 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
487fn 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
506fn 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
531fn 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
549fn 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 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 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 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
641fn 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
685fn 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
714fn 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
726fn 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
739fn 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
758fn 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}