Skip to main content

nemo_relay/codec/
anthropic.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Built-in codec for the Anthropic Messages API.
5//!
6//! Implements [`LlmCodec`] (request decode/encode) and [`LlmResponseCodec`]
7//! (response decode) for the Anthropic Messages API format.
8//!
9//! # Anthropic-specific patterns handled
10//!
11//! - **Content blocks**: Heterogeneous array of `text`, `tool_use`, `thinking`,
12//!   `redacted_thinking`, `mcp_tool_use`, `server_tool_use` blocks
13//! - **Top-level system**: System prompt is a top-level field, not inside messages
14//! - **stop_reason**: Maps to [`FinishReason`] (not `finish_reason`)
15//! - **Tool definitions**: Uses `input_schema` instead of `parameters`
16//! - **Tool choice**: `{"type":"auto"}` / `{"type":"any"}` / `{"type":"tool","name":"..."}`
17//! - **Cache tokens**: `cache_read_input_tokens` / `cache_creation_input_tokens`
18
19use serde::Deserialize;
20
21use crate::api::llm::LlmRequest;
22use crate::error::{FlowError, Result};
23use crate::json::Json;
24
25use super::request::{
26    AnnotatedLlmRequest, FunctionDefinition, GenerationParams, Message, MessageContent, ToolChoice,
27    ToolChoiceFunction, ToolChoiceFunctionName, ToolDefinition,
28};
29use super::response::{
30    AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
31    estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
32};
33use super::traits::{LlmCodec, LlmResponseCodec};
34
35// ---------------------------------------------------------------------------
36// Public codec struct
37// ---------------------------------------------------------------------------
38
39/// Built-in codec for the Anthropic Messages API.
40pub struct AnthropicMessagesCodec;
41
42// ---------------------------------------------------------------------------
43// Private intermediate serde structs for response decode
44// ---------------------------------------------------------------------------
45
46#[derive(Deserialize)]
47struct RawAnthropicResponse {
48    id: Option<String>,
49    #[serde(rename = "type")]
50    object_type: Option<String>,
51    role: Option<String>,
52    model: Option<String>,
53    content: Option<Vec<Json>>,
54    stop_reason: Option<String>,
55    stop_sequence: Option<String>,
56    service_tier: Option<String>,
57    container: Option<Json>,
58    usage: Option<RawAnthropicUsage>,
59    #[serde(flatten)]
60    extra: serde_json::Map<String, Json>,
61}
62
63#[derive(Deserialize)]
64struct RawAnthropicUsage {
65    input_tokens: Option<u64>,
66    output_tokens: Option<u64>,
67    cache_read_input_tokens: Option<u64>,
68    cache_creation_input_tokens: Option<u64>,
69    #[serde(rename = "cost_usd")]
70    provider_cost: Option<f64>,
71    cost: Option<RawUsageCost>,
72}
73
74// ---------------------------------------------------------------------------
75// Helper functions
76// ---------------------------------------------------------------------------
77
78/// Map Anthropic `stop_reason` string to normalized [`FinishReason`].
79fn map_anthropic_stop_reason(reason: &str) -> FinishReason {
80    match reason {
81        "end_turn" => FinishReason::Complete,
82        "max_tokens" => FinishReason::Length,
83        "tool_use" => FinishReason::ToolUse,
84        other => FinishReason::Unknown(other.to_string()),
85    }
86}
87
88/// Helper to construct a [`Json`] number from an `f64`.
89fn json_f64(v: f64) -> Json {
90    serde_json::Number::from_f64(v)
91        .map(Json::Number)
92        .unwrap_or(Json::Null)
93}
94
95/// Keys that are modeled in [`AnnotatedLlmRequest`] and should NOT go into `extra`.
96const MODELED_REQUEST_KEYS: &[&str] = &[
97    "system",
98    "messages",
99    "model",
100    "max_tokens",
101    "temperature",
102    "top_p",
103    "stop_sequences",
104    "tools",
105    "tool_choice",
106    "metadata",
107    "service_tier",
108];
109
110/// Decode the Anthropic `tool_choice` JSON value into a normalized [`ToolChoice`].
111///
112/// Anthropic format:
113/// - `{"type": "auto"}` -> `ToolChoice::Auto`
114/// - `{"type": "any"}` -> `ToolChoice::Required`
115/// - `{"type": "none"}` -> `ToolChoice::None`
116/// - `{"type": "tool", "name": "X"}` -> `ToolChoice::Specific`
117fn decode_anthropic_tool_choice(val: &Json) -> Option<ToolChoice> {
118    let obj = val.as_object()?;
119    let tc_type = obj.get("type")?.as_str()?;
120    match tc_type {
121        "auto" => Some(ToolChoice::Auto),
122        "any" => Some(ToolChoice::Required),
123        "none" => Some(ToolChoice::None),
124        "tool" => {
125            let name = obj.get("name")?.as_str()?.to_string();
126            Some(ToolChoice::Specific(ToolChoiceFunction {
127                choice_type: "function".into(),
128                function: ToolChoiceFunctionName { name },
129            }))
130        }
131        _ => None,
132    }
133}
134
135/// Extract Anthropic `disable_parallel_tool_use` from tool_choice and map
136/// to normalized `parallel_tool_calls` semantics.
137fn decode_parallel_tool_calls(val: &Json) -> Option<bool> {
138    let obj = val.as_object()?;
139    obj.get("disable_parallel_tool_use")
140        .and_then(|v| v.as_bool())
141        .map(|disabled| !disabled)
142}
143
144/// Encode a normalized [`ToolChoice`] back into Anthropic JSON format.
145fn encode_anthropic_tool_choice(tc: &ToolChoice) -> Json {
146    match tc {
147        ToolChoice::Auto => serde_json::json!({"type": "auto"}),
148        ToolChoice::Required => serde_json::json!({"type": "any"}),
149        ToolChoice::None => serde_json::json!({"type": "none"}),
150        ToolChoice::Specific(func) => {
151            serde_json::json!({"type": "tool", "name": func.function.name})
152        }
153    }
154}
155
156fn encode_tool_choice_with_parallel_hint(
157    tc: &ToolChoice,
158    parallel_tool_calls: Option<bool>,
159) -> Json {
160    let mut value = encode_anthropic_tool_choice(tc);
161    if let (Some(parallel), Some(obj)) = (parallel_tool_calls, value.as_object_mut()) {
162        obj.insert("disable_parallel_tool_use".into(), Json::Bool(!parallel));
163    }
164    value
165}
166
167/// Extract the system prompt from an Anthropic top-level `system` field.
168///
169/// Handles both string and array-of-content-blocks formats.
170fn extract_system_message(system_val: &Json) -> Option<Message> {
171    if let Some(s) = system_val.as_str() {
172        Some(Message::System {
173            content: MessageContent::Text(s.to_string()),
174            name: None,
175        })
176    } else if let Some(arr) = system_val.as_array() {
177        // Array of content blocks -- extract text from each "text" block.
178        let texts: Vec<&str> = arr
179            .iter()
180            .filter_map(|block| {
181                let block_type = block.get("type")?.as_str()?;
182                if block_type == "text" {
183                    block.get("text")?.as_str()
184                } else {
185                    None
186                }
187            })
188            .collect();
189        if texts.is_empty() {
190            None
191        } else {
192            Some(Message::System {
193                content: MessageContent::Text(texts.join("\n")),
194                name: None,
195            })
196        }
197    } else {
198        None
199    }
200}
201
202/// Extract system text from a [`Message::System`] for encoding back to top-level.
203fn extract_system_text(msg: &Message) -> Option<String> {
204    match msg {
205        Message::System {
206            content: MessageContent::Text(s),
207            ..
208        } => Some(s.clone()),
209        Message::System {
210            content: MessageContent::Parts(parts),
211            ..
212        } => {
213            let texts: Vec<&str> = parts
214                .iter()
215                .filter_map(|p| match p {
216                    super::request::ContentPart::Text { text } => Some(text.as_str()),
217                    super::request::ContentPart::ImageUrl { .. } => None,
218                })
219                .collect();
220            if texts.is_empty() {
221                None
222            } else {
223                Some(texts.join("\n"))
224            }
225        }
226        _ => None,
227    }
228}
229
230fn split_system_and_messages(messages: &[Message]) -> (Option<String>, Vec<&Message>) {
231    let mut system_text = None;
232    let mut non_system_messages = Vec::new();
233
234    for msg in messages {
235        if let Some(text) = extract_system_text(msg) {
236            system_text = Some(text);
237        } else {
238            non_system_messages.push(msg);
239        }
240    }
241
242    (system_text, non_system_messages)
243}
244
245fn insert_serialized<T: serde::Serialize>(
246    obj: &mut serde_json::Map<String, Json>,
247    key: &str,
248    value: &T,
249    context: &str,
250) -> Result<()> {
251    let json = serde_json::to_value(value)
252        .map_err(|e| FlowError::Internal(format!("Anthropic Messages {context} encode: {e}")))?;
253    obj.insert(key.into(), json);
254    Ok(())
255}
256
257fn overlay_generation_params(obj: &mut serde_json::Map<String, Json>, params: &GenerationParams) {
258    if let Some(temp) = params.temperature {
259        obj.insert("temperature".into(), json_f64(temp));
260    }
261    if let Some(top_p) = params.top_p {
262        obj.insert("top_p".into(), json_f64(top_p));
263    }
264    if let Some(max_tokens) = params.max_tokens {
265        obj.insert("max_tokens".into(), Json::from(max_tokens));
266    }
267}
268
269fn encode_anthropic_tools(tools: &[ToolDefinition]) -> Vec<Json> {
270    tools
271        .iter()
272        .map(|td| {
273            let mut tool = serde_json::Map::new();
274            tool.insert("name".into(), Json::String(td.function.name.clone()));
275            if let Some(ref desc) = td.function.description {
276                tool.insert("description".into(), Json::String(desc.clone()));
277            }
278            if let Some(ref params) = td.function.parameters {
279                tool.insert("input_schema".into(), params.clone());
280            }
281            Json::Object(tool)
282        })
283        .collect()
284}
285
286fn anthropic_text_message(content_blocks: Option<&[Json]>) -> Option<MessageContent> {
287    let text_parts: Vec<&str> = content_blocks
288        .map(|blocks| blocks.iter().filter_map(anthropic_text_block).collect())
289        .unwrap_or_default();
290
291    (!text_parts.is_empty()).then(|| MessageContent::Text(text_parts.join("\n")))
292}
293
294fn anthropic_text_block(block: &Json) -> Option<&str> {
295    if block.get("type")?.as_str()? != "text" {
296        return None;
297    }
298    block.get("text")?.as_str()
299}
300
301fn anthropic_tool_calls(content_blocks: Option<&[Json]>) -> Option<Vec<ResponseToolCall>> {
302    let tool_calls: Vec<ResponseToolCall> = content_blocks
303        .map(|blocks| {
304            blocks
305                .iter()
306                .filter_map(anthropic_tool_call_block)
307                .collect()
308        })
309        .unwrap_or_default();
310
311    (!tool_calls.is_empty()).then_some(tool_calls)
312}
313
314fn anthropic_tool_call_block(block: &Json) -> Option<ResponseToolCall> {
315    if block.get("type")?.as_str()? != "tool_use" {
316        return None;
317    }
318    Some(ResponseToolCall {
319        id: block.get("id")?.as_str()?.to_string(),
320        name: block.get("name")?.as_str()?.to_string(),
321        // CRITICAL: input is already parsed JSON -- clone directly.
322        arguments: block.get("input")?.clone(),
323    })
324}
325
326fn anthropic_usage(
327    raw_usage: Option<RawAnthropicUsage>,
328    model_for_pricing: Option<&str>,
329) -> Option<Usage> {
330    let model_provider = infer_model_provider("anthropic", model_for_pricing);
331    raw_usage.map(|u| {
332        let prompt = u.input_tokens;
333        let completion = u.output_tokens;
334        let mut usage = Usage {
335            prompt_tokens: prompt,
336            completion_tokens: completion,
337            // Anthropic does not supply total_tokens; compute it.
338            total_tokens: match (prompt, completion) {
339                (Some(p), Some(c)) => Some(p + c),
340                _ => None,
341            },
342            cache_read_tokens: u.cache_read_input_tokens,
343            cache_write_tokens: u.cache_creation_input_tokens,
344            cost: provider_reported_cost(u.provider_cost, u.cost),
345        };
346        if usage.cost.is_none() {
347            usage.cost = model_for_pricing.and_then(|model| {
348                estimate_cost_for_provider(model_provider.as_deref(), model, &usage)
349            });
350        }
351        usage
352    })
353}
354
355// ---------------------------------------------------------------------------
356// LlmResponseCodec implementation
357// ---------------------------------------------------------------------------
358
359impl LlmResponseCodec for AnthropicMessagesCodec {
360    fn decode_response(&self, response: &Json) -> Result<AnnotatedLlmResponse> {
361        let raw: RawAnthropicResponse = serde_json::from_value(response.clone())
362            .map_err(|e| FlowError::Internal(format!("Anthropic Messages response decode: {e}")))?;
363
364        let content_blocks = raw.content.as_deref();
365        let message = anthropic_text_message(content_blocks);
366        // Extract tool_use blocks (only "tool_use" type, NOT mcp_tool_use or server_tool_use).
367        let tool_calls = anthropic_tool_calls(content_blocks);
368
369        // Map stop_reason to FinishReason.
370        let finish_reason = raw.stop_reason.as_deref().map(map_anthropic_stop_reason);
371
372        // Map usage.
373        let usage = anthropic_usage(raw.usage, raw.model.as_deref());
374
375        // Build API-specific fields: all content blocks + stop_sequence.
376        let api_specific_content_blocks = raw.content.clone();
377        let api_specific = Some(ApiSpecificResponse::AnthropicMessages {
378            object_type: raw.object_type,
379            role: raw.role,
380            stop_reason: raw.stop_reason,
381            stop_sequence: raw.stop_sequence,
382            service_tier: raw.service_tier,
383            container: raw.container,
384            content_blocks: api_specific_content_blocks,
385        });
386
387        Ok(AnnotatedLlmResponse {
388            id: raw.id,
389            model: raw.model,
390            message,
391            tool_calls,
392            finish_reason,
393            usage,
394            api_specific,
395            extra: raw.extra,
396        })
397    }
398}
399
400// ---------------------------------------------------------------------------
401// LlmCodec implementation
402// ---------------------------------------------------------------------------
403
404impl LlmCodec for AnthropicMessagesCodec {
405    fn decode(&self, request: &LlmRequest) -> Result<AnnotatedLlmRequest> {
406        let obj = request
407            .content
408            .as_object()
409            .ok_or_else(|| FlowError::Internal("request content is not an object".into()))?;
410
411        // Extract system from top-level field.
412        let system_msg = obj.get("system").and_then(extract_system_message);
413
414        // Extract messages (default to empty vec if absent).
415        let mut messages: Vec<Message> = obj
416            .get("messages")
417            .map(|v| serde_json::from_value(v.clone()).unwrap_or_default())
418            .unwrap_or_default();
419
420        // Prepend system message if present.
421        if let Some(sys) = system_msg {
422            messages.insert(0, sys);
423        }
424
425        // Extract model.
426        let model = obj.get("model").and_then(|v| v.as_str()).map(String::from);
427
428        // Extract generation params.
429        let temperature = obj.get("temperature").and_then(|v| v.as_f64());
430        let top_p = obj.get("top_p").and_then(|v| v.as_f64());
431        let max_tokens = obj.get("max_tokens").and_then(|v| v.as_u64());
432        // Anthropic uses stop_sequences (not stop).
433        let stop = obj
434            .get("stop_sequences")
435            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok());
436
437        let params =
438            if temperature.is_some() || max_tokens.is_some() || top_p.is_some() || stop.is_some() {
439                Some(GenerationParams {
440                    temperature,
441                    max_tokens,
442                    top_p,
443                    stop,
444                })
445            } else {
446                None
447            };
448
449        // Extract tools: Anthropic uses flat structure (name, description, input_schema).
450        // Normalize to ToolDefinition { type: "function", function: { name, description, parameters } }.
451        let tools: Option<Vec<ToolDefinition>> = obj.get("tools").and_then(|v| {
452            let arr = v.as_array()?;
453            let defs: Vec<ToolDefinition> = arr
454                .iter()
455                .filter_map(|tool| {
456                    let name = tool.get("name")?.as_str()?.to_string();
457                    let description = tool
458                        .get("description")
459                        .and_then(|d| d.as_str())
460                        .map(String::from);
461                    let parameters = tool.get("input_schema").cloned();
462                    Some(ToolDefinition {
463                        tool_type: "function".into(),
464                        function: FunctionDefinition {
465                            name,
466                            description,
467                            parameters,
468                        },
469                    })
470                })
471                .collect();
472            if defs.is_empty() { None } else { Some(defs) }
473        });
474
475        // Extract tool_choice: Anthropic format.
476        let tool_choice = obj
477            .get("tool_choice")
478            .and_then(decode_anthropic_tool_choice);
479        let parallel_tool_calls = obj.get("tool_choice").and_then(decode_parallel_tool_calls);
480
481        // Collect extra fields (keys not in MODELED_REQUEST_KEYS).
482        let extra: serde_json::Map<String, Json> = obj
483            .iter()
484            .filter(|(k, _)| !MODELED_REQUEST_KEYS.contains(&k.as_str()))
485            .map(|(k, v)| (k.clone(), v.clone()))
486            .collect();
487
488        Ok(AnnotatedLlmRequest {
489            messages,
490            model,
491            params,
492            tools,
493            tool_choice,
494            store: None,
495            previous_response_id: None,
496            truncation: None,
497            reasoning: None,
498            include: None,
499            user: None,
500            metadata: obj.get("metadata").cloned(),
501            service_tier: obj
502                .get("service_tier")
503                .and_then(|v| v.as_str())
504                .map(String::from),
505            parallel_tool_calls,
506            max_output_tokens: None,
507            max_tool_calls: None,
508            top_logprobs: None,
509            stream: None,
510            extra,
511        })
512    }
513
514    fn encode(&self, annotated: &AnnotatedLlmRequest, original: &LlmRequest) -> Result<LlmRequest> {
515        let mut content = original.content.clone();
516        let obj = content
517            .as_object_mut()
518            .ok_or_else(|| FlowError::Internal("original content is not an object".into()))?;
519
520        let (system_text, non_system_messages) = split_system_and_messages(&annotated.messages);
521
522        if let Some(text) = system_text {
523            obj.insert("system".into(), Json::String(text));
524        }
525
526        // Overlay messages (non-system only).
527        insert_serialized(obj, "messages", &non_system_messages, "messages")?;
528
529        // Overlay model if present.
530        if let Some(ref model) = annotated.model {
531            obj.insert("model".into(), Json::String(model.clone()));
532        }
533
534        // Overlay generation params.
535        if let Some(ref params) = annotated.params {
536            overlay_generation_params(obj, params);
537            // Write stop_sequences (Anthropic key name, not "stop").
538            if let Some(ref stop) = params.stop {
539                insert_serialized(obj, "stop_sequences", stop, "stop_sequences")?;
540            }
541        }
542
543        // Overlay tools in Anthropic format: { name, description, input_schema }.
544        // Denormalize from ToolDefinition (drop type/function wrapper, rename parameters -> input_schema).
545        if let Some(ref tools) = annotated.tools {
546            let anthropic_tools = encode_anthropic_tools(tools);
547            insert_serialized(obj, "tools", &anthropic_tools, "tools")?;
548        }
549
550        // Overlay tool_choice in Anthropic format.
551        if let Some(ref tool_choice) = annotated.tool_choice {
552            obj.insert(
553                "tool_choice".into(),
554                encode_tool_choice_with_parallel_hint(tool_choice, annotated.parallel_tool_calls),
555            );
556        }
557
558        if let Some(ref metadata) = annotated.metadata {
559            obj.insert("metadata".into(), metadata.clone());
560        }
561        if let Some(ref service_tier) = annotated.service_tier {
562            obj.insert("service_tier".into(), Json::String(service_tier.clone()));
563        }
564
565        // Merge extra fields back.
566        for (k, v) in &annotated.extra {
567            obj.insert(k.clone(), v.clone());
568        }
569
570        Ok(LlmRequest {
571            headers: original.headers.clone(),
572            content,
573        })
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Streaming codec
579// ---------------------------------------------------------------------------
580
581/// Streaming counterpart to [`AnthropicMessagesCodec`].
582///
583/// Replays the Anthropic Messages SSE event sequence into the same JSON shape Anthropic returns
584/// for a non-streaming request (`{id, type, role, model, content, stop_reason, stop_sequence,
585/// usage}`). Once finalized, the assembled JSON can be fed back through
586/// [`AnthropicMessagesCodec::decode_response`] to produce an
587/// [`AnnotatedLlmResponse`] — meaning streaming and
588/// non-streaming Anthropic requests converge on the same observability output.
589///
590/// Internal state lives behind `Arc<Mutex<...>>` so the `&self`-produced collector and finalizer
591/// closures share access. Each instance is single-use because [`LlmFinalizerFn`] consumes the
592/// finalize step.
593///
594/// [`LlmFinalizerFn`]: crate::api::runtime::LlmFinalizerFn
595pub struct AnthropicMessagesStreamingCodec {
596    state: std::sync::Arc<std::sync::Mutex<AnthropicMessagesStreamingState>>,
597}
598
599impl AnthropicMessagesStreamingCodec {
600    /// Creates a fresh streaming codec with empty accumulator state.
601    pub fn new() -> Self {
602        Self {
603            state: std::sync::Arc::new(std::sync::Mutex::new(
604                AnthropicMessagesStreamingState::default(),
605            )),
606        }
607    }
608}
609
610impl Default for AnthropicMessagesStreamingCodec {
611    fn default() -> Self {
612        Self::new()
613    }
614}
615
616impl super::streaming::StreamingCodec for AnthropicMessagesStreamingCodec {
617    fn collector(&self) -> crate::api::runtime::LlmCollectorFn {
618        let state = std::sync::Arc::clone(&self.state);
619        Box::new(move |event: Json| -> Result<()> {
620            let mut guard = state
621                .lock()
622                .unwrap_or_else(|poisoned| poisoned.into_inner());
623            guard.observe(&event);
624            Ok(())
625        })
626    }
627
628    fn finalizer(&self) -> crate::api::runtime::LlmFinalizerFn {
629        let state = std::sync::Arc::clone(&self.state);
630        Box::new(move || -> Json {
631            let mut guard = state
632                .lock()
633                .unwrap_or_else(|poisoned| poisoned.into_inner());
634            // Move state out so finalize can consume it; the codec is single-use, so leaving a
635            // default behind is intentional and never observed by another caller.
636            std::mem::take(&mut *guard).finalize()
637        })
638    }
639}
640
641#[derive(Debug, Default)]
642struct AnthropicMessagesStreamingState {
643    id: Option<String>,
644    type_: Option<String>,
645    role: Option<String>,
646    model: Option<String>,
647    /// Latest usage snapshot. `message_start` carries an initial value (input tokens, zero output
648    /// so far); `message_delta` updates it cumulatively. Last write wins.
649    usage: Option<Json>,
650    stop_reason: Option<String>,
651    /// Stored as raw `Json` to preserve `null` (Anthropic's wire shape) versus omitted.
652    stop_sequence: Option<Json>,
653    /// Indexed by the SSE event's `index` field. `None` slots accommodate sparse indices though
654    /// Anthropic emits them in order today.
655    blocks: Vec<Option<StreamingBlock>>,
656}
657
658#[derive(Debug, Default, Clone)]
659struct StreamingBlock {
660    /// The `content_block` JSON captured at `content_block_start`. Deltas mutate fields directly
661    /// for blocks Anthropic delivers incrementally (text, tool_use input, citations); other block
662    /// types (server_tool_use results) ship complete at start and pass through unchanged.
663    skeleton: serde_json::Map<String, Json>,
664    text: String,
665    has_text: bool,
666    partial_json: String,
667    has_partial_json: bool,
668    citations: Vec<Json>,
669    has_citations: bool,
670}
671
672impl AnthropicMessagesStreamingState {
673    fn observe(&mut self, event: &Json) {
674        let event_type = event.get("type").and_then(Json::as_str).unwrap_or("");
675        match event_type {
676            "message_start" => self.observe_message_start(event),
677            "content_block_start" => self.observe_content_block_start(event),
678            "content_block_delta" => self.observe_content_block_delta(event),
679            "message_delta" => self.observe_message_delta(event),
680            // content_block_stop, message_stop, ping, and any unknown event type carry no
681            // accumulator-relevant payload. Unknown types are ignored rather than erroring so a
682            // future Anthropic event addition does not break observability.
683            _ => {}
684        }
685    }
686
687    fn observe_message_start(&mut self, event: &Json) {
688        let Some(message) = event.get("message") else {
689            return;
690        };
691        if let Some(id) = message.get("id").and_then(Json::as_str) {
692            self.id = Some(id.to_string());
693        }
694        if let Some(model) = message.get("model").and_then(Json::as_str) {
695            self.model = Some(model.to_string());
696        }
697        if let Some(role) = message.get("role").and_then(Json::as_str) {
698            self.role = Some(role.to_string());
699        }
700        if let Some(t) = message.get("type").and_then(Json::as_str) {
701            self.type_ = Some(t.to_string());
702        }
703        if let Some(usage) = message.get("usage") {
704            self.usage = Some(usage.clone());
705        }
706    }
707
708    fn observe_content_block_start(&mut self, event: &Json) {
709        let Some(index) = event.get("index").and_then(Json::as_u64) else {
710            return;
711        };
712        let Some(content_block) = event.get("content_block") else {
713            return;
714        };
715        let skeleton = match content_block {
716            Json::Object(map) => map.clone(),
717            _ => return,
718        };
719        let index = index as usize;
720        while self.blocks.len() <= index {
721            self.blocks.push(None);
722        }
723        self.blocks[index] = Some(StreamingBlock {
724            skeleton,
725            ..StreamingBlock::default()
726        });
727    }
728
729    fn observe_content_block_delta(&mut self, event: &Json) {
730        let Some(index) = event.get("index").and_then(Json::as_u64) else {
731            return;
732        };
733        let index = index as usize;
734        let Some(delta) = event.get("delta") else {
735            return;
736        };
737        let delta_type = delta.get("type").and_then(Json::as_str).unwrap_or("");
738        let Some(slot) = self.blocks.get_mut(index) else {
739            return;
740        };
741        let Some(block) = slot.as_mut() else { return };
742        match delta_type {
743            "text_delta" => {
744                if let Some(text) = delta.get("text").and_then(Json::as_str) {
745                    block.text.push_str(text);
746                    block.has_text = true;
747                }
748            }
749            "input_json_delta" => {
750                if let Some(partial) = delta.get("partial_json").and_then(Json::as_str) {
751                    block.partial_json.push_str(partial);
752                    block.has_partial_json = true;
753                }
754            }
755            "citations_delta" => {
756                if let Some(citation) = delta.get("citation") {
757                    block.citations.push(citation.clone());
758                    block.has_citations = true;
759                }
760            }
761            // thinking_delta, signature_delta, and any future delta types fall through; the block
762            // skeleton retains whatever shape was set at content_block_start.
763            _ => {}
764        }
765    }
766
767    fn observe_message_delta(&mut self, event: &Json) {
768        if let Some(delta) = event.get("delta") {
769            if let Some(reason) = delta.get("stop_reason").and_then(Json::as_str) {
770                self.stop_reason = Some(reason.to_string());
771            }
772            if let Some(seq) = delta.get("stop_sequence") {
773                self.stop_sequence = Some(seq.clone());
774            }
775        }
776        if let Some(usage) = event.get("usage") {
777            self.usage = Some(usage.clone());
778        }
779    }
780
781    fn finalize(self) -> Json {
782        let mut output = serde_json::Map::new();
783        if let Some(id) = self.id {
784            output.insert("id".to_string(), Json::String(id));
785        }
786        if let Some(t) = self.type_ {
787            output.insert("type".to_string(), Json::String(t));
788        }
789        if let Some(role) = self.role {
790            output.insert("role".to_string(), Json::String(role));
791        }
792        if let Some(model) = self.model {
793            output.insert("model".to_string(), Json::String(model));
794        }
795        let content: Vec<Json> = self
796            .blocks
797            .into_iter()
798            .filter_map(|block| block.map(StreamingBlock::finalize))
799            .collect();
800        output.insert("content".to_string(), Json::Array(content));
801        if let Some(reason) = self.stop_reason {
802            output.insert("stop_reason".to_string(), Json::String(reason));
803        }
804        if let Some(seq) = self.stop_sequence {
805            output.insert("stop_sequence".to_string(), seq);
806        }
807        if let Some(usage) = self.usage {
808            output.insert("usage".to_string(), usage);
809        }
810        Json::Object(output)
811    }
812}
813
814impl StreamingBlock {
815    fn finalize(mut self) -> Json {
816        if self.has_text {
817            self.skeleton
818                .insert("text".to_string(), Json::String(self.text));
819        }
820        if self.has_partial_json {
821            // Concatenated `partial_json` fragments are expected to parse as a JSON object — that's
822            // the assembled tool input. If parsing fails (Anthropic emits malformed deltas, stream
823            // truncated mid-block), surface the raw concatenation so observability still captures
824            // something rather than dropping the call.
825            let parsed = match serde_json::from_str::<Json>(&self.partial_json) {
826                Ok(value) => value,
827                Err(_) => Json::String(self.partial_json),
828            };
829            self.skeleton.insert("input".to_string(), parsed);
830        }
831        if self.has_citations {
832            self.skeleton
833                .insert("citations".to_string(), Json::Array(self.citations));
834        }
835        Json::Object(self.skeleton)
836    }
837}
838
839// ---------------------------------------------------------------------------
840// Tests
841// ---------------------------------------------------------------------------
842
843#[cfg(test)]
844#[path = "../../tests/unit/codec/anthropic_tests.rs"]
845mod tests;