Skip to main content

mermaid_cli/providers/model/
meta.rs

1//! Meta Model API provider using the Responses endpoint.
2//!
3//! Unlike Meta's OpenAI-compatible Chat Completions surface, Responses can
4//! carry encrypted reasoning across tool turns. Mermaid uses stateless replay:
5//! every request sets `store: false`, asks for `reasoning.encrypted_content`,
6//! and persists the returned output items on the assistant message.
7
8use std::collections::{HashMap, HashSet};
9use std::time::Duration;
10
11use async_trait::async_trait;
12use futures::StreamExt;
13use reqwest::Client;
14use serde_json::{Value, json};
15
16use mermaid_domain::ChatRequest;
17use mermaid_model::models::tool_call::{FunctionCall, ToolCall};
18use mermaid_model::models::{
19    BackendError, FinishReason, MessageRole, MetaResponseItem, ModelError, ProviderContinuation,
20    ReasoningCapability, ReasoningChunk, ReasoningLevel, Result, TokenUsage, nearest_effort,
21};
22use mermaid_model::utils::drain_sse_events;
23
24use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
25use super::ModelProvider;
26use mermaid_model::models::ModelCapabilities;
27
28pub const DEFAULT_BASE_URL: &str = "https://api.meta.ai/v1";
29pub const DEFAULT_API_KEY_ENV: &str = "MODEL_API_KEY";
30
31pub struct MetaProvider {
32    client: Client,
33    base_url: String,
34    api_key: String,
35    model_name: String,
36    extra_headers: HashMap<String, String>,
37    capabilities: ModelCapabilities,
38}
39
40impl MetaProvider {
41    /// Build the Responses-API provider for a Meta model.
42    ///
43    /// # Errors
44    ///
45    /// Only the HTTP client build, as [`BackendError::ConnectionFailed`]. The
46    /// API is not contacted here; a `model_name` outside the `muse-spark`
47    /// family is not an error either — it simply advertises no documented
48    /// context or output limits.
49    pub fn new(
50        api_key: String,
51        model_name: String,
52        base_url: String,
53        extra_headers: HashMap<String, String>,
54    ) -> Result<Self> {
55        let client = Client::builder()
56            .pool_max_idle_per_host(10)
57            .pool_idle_timeout(Duration::from_secs(90))
58            .tcp_keepalive(Duration::from_secs(60))
59            .connect_timeout(Duration::from_secs(10))
60            .build()
61            .map_err(|error| {
62                ModelError::Backend(BackendError::ConnectionFailed {
63                    backend: "meta".to_string(),
64                    url: base_url.clone(),
65                    reason: error.to_string(),
66                })
67            })?;
68        // Prefix, not exact-id: a future muse-spark-1.2 should inherit the
69        // documented family limits instead of regressing to "unknown".
70        let muse_spark = model_name.to_ascii_lowercase().starts_with("muse-spark");
71        let capabilities = ModelCapabilities {
72            supports_tools: true,
73            supports_vision: true,
74            supports_reasoning: ReasoningCapability::Levels(meta_reasoning_levels()),
75            max_context_tokens: muse_spark
76                .then_some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
77            max_output_tokens: muse_spark
78                .then_some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS),
79            emits_provider_continuation: true,
80        };
81        Ok(Self {
82            client,
83            base_url,
84            api_key,
85            model_name,
86            extra_headers,
87            capabilities,
88        })
89    }
90
91    async fn start_response(
92        &self,
93        request: &ChatRequest,
94        ctx: &StreamContext,
95    ) -> Result<reqwest::Response> {
96        let url = format!("{}/responses", self.base_url.trim_end_matches('/'));
97        let body = build_request_body(request, &self.model_name);
98        let mut builder = self
99            .client
100            .post(&url)
101            .bearer_auth(&self.api_key)
102            .header("Accept", "text/event-stream")
103            .json(&body);
104        for (name, value) in &self.extra_headers {
105            builder = builder.header(name, value);
106        }
107        let response = tokio::select! {
108            biased;
109            _ = ctx.token.cancelled() => return Err(ModelError::Cancelled),
110            response = builder.send() => response.map_err(|error| {
111                ModelError::Backend(BackendError::ConnectionFailed {
112                    backend: "meta".to_string(),
113                    url,
114                    reason: error.to_string(),
115                })
116            })?,
117        };
118        if response.status().is_success() {
119            Ok(response)
120        } else {
121            Err(http_error(response, &ctx.token).await)
122        }
123    }
124}
125
126#[async_trait]
127impl ModelProvider for MetaProvider {
128    fn capabilities(&self) -> &ModelCapabilities {
129        &self.capabilities
130    }
131
132    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
133        let response = self.start_response(&request, &ctx).await?;
134        let mut stream = response.bytes_stream();
135        let mut buffer = Vec::new();
136        let mut state = ResponseState::default();
137
138        loop {
139            let chunk = tokio::select! {
140                biased;
141                _ = ctx.token.cancelled() => return Err(ModelError::Cancelled),
142                chunk = stream.next() => chunk,
143            };
144            let Some(chunk) = chunk else {
145                return Err(ModelError::StreamError(
146                    "Meta Responses stream closed before a terminal event".to_string(),
147                ));
148            };
149            // Bound SSE reassembly like every other streaming adapter: a
150            // server that streams bytes but never emits the `\n\n` event
151            // separator would otherwise grow `buffer` without bound (#50).
152            if buffer.len() > mermaid_model::constants::MAX_SSE_BUFFER_BYTES {
153                return Err(ModelError::StreamError(format!(
154                    "SSE stream exceeded {} byte reassembly cap without a complete event",
155                    mermaid_model::constants::MAX_SSE_BUFFER_BYTES
156                )));
157            }
158            buffer.extend_from_slice(&chunk.map_err(|error| {
159                ModelError::StreamError(format!("Meta Responses stream failed: {error}"))
160            })?);
161
162            for payload in drain_sse_events(&mut buffer) {
163                let event: Value =
164                    serde_json::from_str(&payload).map_err(|error| ModelError::ParseError {
165                        message: format!("failed to parse Meta Responses event: {error}"),
166                        raw: None,
167                    })?;
168                if let Some(final_response) = handle_event(event, &ctx, &mut state).await? {
169                    return Ok(final_response);
170                }
171            }
172        }
173    }
174}
175
176#[derive(Default)]
177struct ResponseState {
178    emitted_calls: HashSet<String>,
179    tool_calls: Vec<ToolCall>,
180}
181
182async fn handle_event(
183    event: Value,
184    ctx: &StreamContext,
185    state: &mut ResponseState,
186) -> Result<Option<FinalResponse>> {
187    let event_type = event
188        .get("type")
189        .and_then(Value::as_str)
190        .unwrap_or_default();
191    match event_type {
192        "response.output_text.delta" => {
193            if let Some(delta) = event.get("delta").and_then(Value::as_str) {
194                send(&ctx.sink, StreamEvent::Text(delta.to_string())).await?;
195            }
196        },
197        "response.reasoning_summary_text.delta" => {
198            if let Some(delta) = event.get("delta").and_then(Value::as_str) {
199                send(
200                    &ctx.sink,
201                    StreamEvent::Reasoning(ReasoningChunk {
202                        text: delta.to_string(),
203                        signature: None,
204                    }),
205                )
206                .await?;
207            }
208        },
209        "response.output_item.done" => {
210            if let Some(item) = event.get("item") {
211                emit_tool_call(item, ctx, state).await?;
212            }
213        },
214        "response.completed" | "response.incomplete" => {
215            let response = event
216                .get("response")
217                .ok_or_else(|| ModelError::ParseError {
218                    message: format!("Meta {event_type} event omitted response"),
219                    raw: None,
220                })?;
221            return terminal_response(response, event_type, ctx, state)
222                .await
223                .map(Some);
224        },
225        "response.failed" | "error" => return Err(meta_failure(&event)),
226        "response.cancelled" => {
227            return Err(ModelError::StreamError(
228                "Meta cancelled the response".to_string(),
229            ));
230        },
231        _ => {},
232    }
233    Ok(None)
234}
235
236async fn terminal_response(
237    response: &Value,
238    event_type: &str,
239    ctx: &StreamContext,
240    state: &mut ResponseState,
241) -> Result<FinalResponse> {
242    let output = response
243        .get("output")
244        .and_then(Value::as_array)
245        .cloned()
246        .unwrap_or_default();
247    for item in &output {
248        emit_tool_call(item, ctx, state).await?;
249    }
250    let continuation = ProviderContinuation::MetaResponses {
251        output: output
252            .into_iter()
253            .filter(meta_item_is_replayable)
254            .map(MetaResponseItem::from_wire)
255            .collect(),
256    };
257    let usage = response.get("usage").map(meta_usage);
258    let stop_reason = meta_finish_reason(response, event_type, !state.tool_calls.is_empty());
259    send(
260        &ctx.sink,
261        StreamEvent::Done {
262            usage: usage.clone(),
263            provider_continuation: Some(continuation.clone()),
264            stop_reason: Some(stop_reason.clone()),
265        },
266    )
267    .await?;
268    Ok(FinalResponse {
269        usage,
270        provider_continuation: Some(continuation),
271        tool_calls: state.tool_calls.clone(),
272        stop_reason: Some(stop_reason),
273    })
274}
275
276async fn emit_tool_call(
277    item: &Value,
278    ctx: &StreamContext,
279    state: &mut ResponseState,
280) -> Result<()> {
281    let Some(call) = tool_call_from_item(item) else {
282        return Ok(());
283    };
284    let call_id = call.id.clone().unwrap_or_default();
285    if state.emitted_calls.insert(call_id) {
286        state.tool_calls.push(call.clone());
287        send(&ctx.sink, StreamEvent::ToolCall(call)).await?;
288    }
289    Ok(())
290}
291
292fn tool_call_from_item(item: &Value) -> Option<ToolCall> {
293    if item.get("type").and_then(Value::as_str) != Some("function_call") {
294        return None;
295    }
296    let call_id = item.get("call_id")?.as_str()?.to_string();
297    let name = item.get("name")?.as_str()?.to_string();
298    let raw_arguments = item
299        .get("arguments")
300        .and_then(Value::as_str)
301        .unwrap_or("{}");
302    let arguments = serde_json::from_str(raw_arguments)
303        .unwrap_or_else(|_| Value::String(raw_arguments.to_string()));
304    Some(ToolCall {
305        id: Some(call_id),
306        function: FunctionCall { name, arguments },
307    })
308}
309
310fn build_request_body(request: &ChatRequest, model_name: &str) -> Value {
311    let effort = nearest_effort(request.reasoning, &meta_reasoning_levels())
312        .unwrap_or(ReasoningLevel::Minimal);
313    let mut body = json!({
314        "model": model_name,
315        "input": messages_to_input(&request.messages),
316        "stream": true,
317        "store": false,
318        "include": ["reasoning.encrypted_content"],
319        "reasoning": {
320            "effort": meta_effort(effort),
321            "summary": "auto",
322        },
323    });
324    // Muse is tuned for Meta's 1.0 default. Mermaid's global 0.7 default was
325    // chosen for other providers, so omit it here unless the user changed it.
326    if (request.temperature - mermaid_model::constants::DEFAULT_TEMPERATURE).abs() > f32::EPSILON {
327        body["temperature"] = json!(request.temperature);
328    }
329    let instructions = combined_instructions(request);
330    if !instructions.is_empty() {
331        body["instructions"] = Value::String(instructions);
332    }
333    if !request.tools.is_empty() {
334        body["tools"] = Value::Array(
335            request
336                .tools
337                .iter()
338                .map(|tool| {
339                    json!({
340                        "type": "function",
341                        "name": tool.name,
342                        "description": tool.description,
343                        "parameters": tool.input_schema,
344                    })
345                })
346                .collect(),
347        );
348    }
349    if request.max_tokens > 0 {
350        let limit = request
351            .resolved_max_output
352            .map_or(request.max_tokens, |max| request.max_tokens.min(max));
353        body["max_output_tokens"] = json!(limit);
354    }
355    body
356}
357
358fn messages_to_input(messages: &[mermaid_model::models::ChatMessage]) -> Vec<Value> {
359    let mut input = Vec::new();
360    for message in messages {
361        if message.role == MessageRole::Assistant
362            && let Some(output) = message
363                .provider_continuation
364                .as_ref()
365                .and_then(ProviderContinuation::meta_output)
366        {
367            input.extend(meta_output_to_input(output));
368            continue;
369        }
370        match message.role {
371            MessageRole::Tool => input.push(json!({
372                "type": "function_call_output",
373                "call_id": message.tool_call_id.clone().unwrap_or_default(),
374                "output": message.content,
375            })),
376            MessageRole::User => input.push(input_message(message, "user", "input_text")),
377            MessageRole::System => input.push(input_message(message, "system", "input_text")),
378            MessageRole::Assistant => {
379                if !message.content.is_empty() {
380                    let mut assistant = input_message(message, "assistant", "output_text");
381                    if message
382                        .tool_calls
383                        .as_ref()
384                        .is_some_and(|calls| !calls.is_empty())
385                    {
386                        assistant["phase"] = json!("commentary");
387                    }
388                    input.push(assistant);
389                }
390                for call in message.tool_calls.iter().flatten() {
391                    input.push(json!({
392                        "type": "function_call",
393                        "call_id": call.id.clone().unwrap_or_default(),
394                        "name": call.function.name,
395                        "arguments": serde_json::to_string(&call.function.arguments)
396                            .unwrap_or_else(|_| "{}".to_string()),
397                        "status": "completed",
398                    }));
399                }
400            },
401        }
402    }
403    input
404}
405
406fn meta_output_to_input(output: &[MetaResponseItem]) -> Vec<Value> {
407    let mut input = output
408        .iter()
409        .map(MetaResponseItem::to_wire)
410        .collect::<Vec<_>>();
411    // Meta rejects a replayed reasoning item followed directly by the next user
412    // turn. A rare reasoning-only response therefore needs a minimal assistant
413    // message before the conversation continues.
414    if input
415        .last()
416        .and_then(|item| item.get("type"))
417        .and_then(Value::as_str)
418        == Some("reasoning")
419    {
420        input.push(json!({
421            "type": "message",
422            "role": "assistant",
423            "content": [{"type": "output_text", "text": "I will continue."}]
424        }));
425    }
426    input
427}
428
429fn input_message(
430    message: &mermaid_model::models::ChatMessage,
431    role: &str,
432    text_type: &str,
433) -> Value {
434    let mut content = Vec::new();
435    if !message.content.is_empty() {
436        content.push(json!({"type": text_type, "text": message.content}));
437    }
438    if role == "user" {
439        for image in message.images.iter().flatten() {
440            content.push(json!({
441                "type": "input_image",
442                "image_url": format!("data:image/png;base64,{image}"),
443            }));
444        }
445    }
446    json!({"type": "message", "role": role, "content": content})
447}
448
449fn combined_instructions(request: &ChatRequest) -> String {
450    match request
451        .instructions
452        .as_deref()
453        .filter(|value| !value.is_empty())
454    {
455        Some(suffix) if !request.system_prompt.is_empty() => {
456            format!("{}\n\n{}", request.system_prompt, suffix)
457        },
458        Some(suffix) => suffix.to_string(),
459        None => request.system_prompt.clone(),
460    }
461}
462
463fn meta_reasoning_levels() -> Vec<ReasoningLevel> {
464    vec![
465        ReasoningLevel::Minimal,
466        ReasoningLevel::Low,
467        ReasoningLevel::Medium,
468        ReasoningLevel::High,
469        ReasoningLevel::XHigh,
470    ]
471}
472
473fn meta_effort(level: ReasoningLevel) -> &'static str {
474    match level {
475        ReasoningLevel::None | ReasoningLevel::Minimal => "minimal",
476        ReasoningLevel::Low => "low",
477        ReasoningLevel::Medium => "medium",
478        ReasoningLevel::High => "high",
479        ReasoningLevel::XHigh | ReasoningLevel::Max => "xhigh",
480    }
481}
482
483fn meta_item_is_replayable(item: &Value) -> bool {
484    item.get("type").and_then(Value::as_str) != Some("reasoning")
485        || item
486            .get("encrypted_content")
487            .and_then(Value::as_str)
488            .is_some()
489}
490
491fn meta_usage(value: &Value) -> TokenUsage {
492    let input = usize_field(value, "input_tokens");
493    let output = usize_field(value, "output_tokens");
494    let cached = value
495        .get("input_tokens_details")
496        .map(|details| usize_field(details, "cached_tokens"))
497        .unwrap_or_default();
498    let reasoning = value
499        .get("output_tokens_details")
500        .map(|details| usize_field(details, "reasoning_tokens"))
501        .unwrap_or_default();
502    // Responses-API wire counts nest cached inside input_tokens and
503    // reasoning inside output_tokens; carve both out so the shared
504    // TokenUsage components stay disjoint (matches openai_compat).
505    TokenUsage::provider(
506        input.saturating_sub(cached),
507        output.saturating_sub(reasoning),
508    )
509    .with_cached_input(cached)
510    .with_reasoning_output(reasoning)
511}
512
513fn usize_field(value: &Value, key: &str) -> usize {
514    value
515        .get(key)
516        .and_then(Value::as_u64)
517        .and_then(|value| usize::try_from(value).ok())
518        .unwrap_or_default()
519}
520
521fn meta_finish_reason(response: &Value, event_type: &str, has_tools: bool) -> FinishReason {
522    let incomplete_reason = response
523        .get("incomplete_details")
524        .and_then(|details| details.get("reason"))
525        .and_then(Value::as_str)
526        .unwrap_or_default();
527    if event_type == "response.incomplete"
528        || response.get("status").and_then(Value::as_str) == Some("incomplete")
529    {
530        if incomplete_reason.contains("max_output") || incomplete_reason.contains("length") {
531            return FinishReason::Length;
532        }
533        if incomplete_reason.contains("content_filter") || incomplete_reason.contains("safety") {
534            return FinishReason::ContentFilter;
535        }
536        return FinishReason::Other(if incomplete_reason.is_empty() {
537            "incomplete".to_string()
538        } else {
539            incomplete_reason.to_string()
540        });
541    }
542    if has_tools {
543        FinishReason::ToolUse
544    } else {
545        FinishReason::Stop
546    }
547}
548
549fn meta_failure(event: &Value) -> ModelError {
550    let error = event
551        .get("response")
552        .and_then(|response| response.get("error"))
553        .or_else(|| event.get("error"));
554    let message = error
555        .and_then(|error| error.get("message"))
556        .and_then(Value::as_str)
557        .or_else(|| event.get("message").and_then(Value::as_str))
558        .unwrap_or("Meta Responses request failed");
559    ModelError::Backend(BackendError::ProviderError {
560        provider: "meta".to_string(),
561        code: error
562            .and_then(|error| error.get("code"))
563            .and_then(Value::as_str)
564            .map(str::to_string),
565        message: mermaid_model::utils::redact_secrets(message),
566        debug: mermaid_model::models::ResponseDebugContext::default(),
567    })
568}
569
570async fn http_error(
571    response: reqwest::Response,
572    token: &tokio_util::sync::CancellationToken,
573) -> ModelError {
574    let status = response.status().as_u16();
575    let debug = mermaid_model::models::ResponseDebugContext::from_headers(response.headers());
576    let body = tokio::select! {
577        biased;
578        _ = token.cancelled() => return ModelError::Cancelled,
579        body = response.text() => body.unwrap_or_else(|_| "Meta request failed".to_string()),
580    };
581    ModelError::Backend(BackendError::HttpError {
582        status,
583        message: mermaid_model::utils::redact_secrets(&body),
584        debug,
585    })
586}
587
588async fn send(sink: &tokio::sync::mpsc::Sender<StreamEvent>, event: StreamEvent) -> Result<()> {
589    sink.send(event)
590        .await
591        .map_err(|_| ModelError::StreamError("stream receiver closed".to_string()))
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::providers::test_stream_context;
598    use mermaid_domain::{ToolDefinition, TurnId};
599    use mermaid_model::models::ChatMessage;
600
601    fn request() -> ChatRequest {
602        ChatRequest {
603            model_id: "meta/muse-spark-1.1".to_string(),
604            messages: vec![ChatMessage::user("hello").with_images(vec!["PNG".to_string()])],
605            system_prompt: "system".to_string(),
606            instructions: Some("project".to_string()),
607            reasoning: ReasoningLevel::Max,
608            temperature: 0.7,
609            max_tokens: 200_000,
610            tools: vec![ToolDefinition {
611                name: "read_file".to_string(),
612                description: "Read a file".to_string(),
613                input_schema: json!({"type": "object"}),
614            }],
615            ollama_num_ctx: None,
616            ollama_allow_ram_offload: None,
617            resolved_context_window: Some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
618            resolved_max_output: Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS),
619            output_schema: None,
620            suppress_auto_compact: false,
621            suppressed_builtin_tools: Vec::new(),
622        }
623    }
624
625    #[test]
626    fn request_uses_stateless_encrypted_replay_shape() {
627        let body = build_request_body(&request(), "muse-spark-1.1");
628        assert_eq!(body["store"], false);
629        assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
630        assert_eq!(body["reasoning"]["effort"], "xhigh");
631        assert_eq!(body["reasoning"]["summary"], "auto");
632        assert_eq!(
633            body["max_output_tokens"],
634            mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS
635        );
636        assert_eq!(body["instructions"], "system\n\nproject");
637        assert_eq!(body["tools"][0]["name"], "read_file");
638        assert!(body.get("temperature").is_none());
639        assert!(body.get("previous_response_id").is_none());
640        assert!(body.get("tool_choice").is_none());
641        assert_eq!(body["input"][0]["content"][1]["type"], "input_image");
642    }
643
644    /// Adapter contract (see `MessageAudience`): harness steering must reach
645    /// the model. The Responses API carries system-role input messages, so the
646    /// reminder passes through in place at the tail.
647    #[test]
648    fn model_directed_system_messages_reach_the_wire_in_place() {
649        use mermaid_model::models::ChatMessageKind;
650        let mut req = request();
651        let mut nudge =
652            mermaid_model::models::ChatMessage::system("Reminder: plan mode is active.");
653        nudge.kind = ChatMessageKind::RecoveryNudge;
654        req.messages.push(nudge);
655        let body = build_request_body(&req, "muse-spark-1.1");
656
657        let input = body["input"].as_array().expect("input array");
658        let last = input.last().expect("non-empty");
659        assert_eq!(last["role"], "system");
660        assert!(
661            serde_json::to_string(&last["content"])
662                .unwrap()
663                .contains("plan mode is active"),
664        );
665    }
666
667    #[test]
668    fn none_reasoning_maps_to_minimal_and_auto_budget_is_omitted() {
669        let mut req = request();
670        req.reasoning = ReasoningLevel::None;
671        req.max_tokens = 0;
672        let body = build_request_body(&req, "muse-spark-1.1");
673        assert_eq!(body["reasoning"]["effort"], "minimal");
674        assert!(body.get("max_output_tokens").is_none());
675    }
676
677    #[test]
678    fn continuation_replays_order_phase_and_encrypted_content() {
679        let output = vec![
680            MetaResponseItem::from_wire(json!({
681                "type": "reasoning",
682                "id": "rs_1",
683                "summary": [],
684                "encrypted_content": "eyJcipher.payload.signature"
685            })),
686            MetaResponseItem::from_wire(json!({
687                "type": "message",
688                "role": "assistant",
689                "phase": "commentary",
690                "content": [{"type": "output_text", "text": "checking"}]
691            })),
692            MetaResponseItem::from_wire(json!({
693                "type": "function_call",
694                "call_id": "call_1",
695                "name": "read_file",
696                "arguments": "{\"path\":\"README.md\"}"
697            })),
698        ];
699        let message = ChatMessage::assistant("checking")
700            .with_provider_continuation(ProviderContinuation::MetaResponses { output });
701        let input = messages_to_input(&[
702            message,
703            ChatMessage::tool("call_1", "read_file", "contents"),
704        ]);
705        assert_eq!(input[0]["type"], "reasoning");
706        assert_eq!(input[0]["encrypted_content"], "eyJcipher.payload.signature");
707        assert_eq!(input[1]["phase"], "commentary");
708        assert_eq!(input[2]["call_id"], "call_1");
709        assert_eq!(input[3]["type"], "function_call_output");
710    }
711
712    #[test]
713    fn reasoning_only_replay_gets_required_assistant_follower() {
714        let output = vec![MetaResponseItem::from_wire(json!({
715            "type": "reasoning",
716            "id": "rs_1",
717            "summary": [],
718            "encrypted_content": "ciphertext"
719        }))];
720        let input = meta_output_to_input(&output);
721        assert_eq!(input[0]["type"], "reasoning");
722        assert_eq!(input[1]["type"], "message");
723        assert_eq!(input[1]["role"], "assistant");
724    }
725
726    #[test]
727    fn parses_tool_calls_usage_and_finish_reasons() {
728        let call = tool_call_from_item(&json!({
729            "type": "function_call",
730            "call_id": "call_7",
731            "name": "execute_command",
732            "arguments": "{\"cmd\":\"pwd\"}"
733        }))
734        .unwrap();
735        assert_eq!(call.id.as_deref(), Some("call_7"));
736        assert_eq!(call.function.arguments["cmd"], "pwd");
737
738        let usage = meta_usage(&json!({
739            "input_tokens": 100,
740            "output_tokens": 40,
741            "total_tokens": 140,
742            "input_tokens_details": {"cached_tokens": 20},
743            "output_tokens_details": {"reasoning_tokens": 15}
744        }));
745        assert_eq!(usage.prompt_tokens, 80, "cached carved out of input");
746        assert_eq!(
747            usage.completion_tokens, 25,
748            "reasoning carved out of output"
749        );
750        assert_eq!(usage.total_tokens(), 140);
751        assert_eq!(usage.cached_input_tokens, 20);
752        assert_eq!(usage.reasoning_output_tokens, 15);
753        assert_eq!(
754            meta_finish_reason(
755                &json!({"status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}}),
756                "response.incomplete",
757                false,
758            ),
759            FinishReason::Length
760        );
761        assert_eq!(
762            meta_finish_reason(&json!({}), "response.completed", true),
763            FinishReason::ToolUse
764        );
765    }
766
767    #[tokio::test]
768    async fn response_events_stream_in_order_and_emit_one_terminal_event() {
769        let (ctx, mut rx) = test_stream_context(TurnId(7));
770        let mut state = ResponseState::default();
771        assert!(
772            handle_event(
773                json!({"type": "response.reasoning_summary_text.delta", "delta": "plan"}),
774                &ctx,
775                &mut state,
776            )
777            .await
778            .unwrap()
779            .is_none()
780        );
781        assert!(
782            handle_event(
783                json!({"type": "response.output_text.delta", "delta": "checking"}),
784                &ctx,
785                &mut state,
786            )
787            .await
788            .unwrap()
789            .is_none()
790        );
791        let function_call = json!({
792            "type": "function_call",
793            "call_id": "call_1",
794            "name": "read_file",
795            "arguments": "{\"path\":\"README.md\"}",
796            "status": "completed"
797        });
798        handle_event(
799            json!({"type": "response.output_item.done", "item": function_call.clone()}),
800            &ctx,
801            &mut state,
802        )
803        .await
804        .unwrap();
805        let final_response = handle_event(
806            json!({
807                "type": "response.completed",
808                "response": {
809                    "status": "completed",
810                    "output": [
811                        {
812                            "type": "reasoning",
813                            "id": "rs_1",
814                            "summary": [],
815                            "encrypted_content": "ciphertext"
816                        },
817                        {
818                            "type": "message",
819                            "role": "assistant",
820                            "phase": "commentary",
821                            "content": [{"type": "output_text", "text": "checking"}]
822                        },
823                        function_call
824                    ],
825                    "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
826                }
827            }),
828            &ctx,
829            &mut state,
830        )
831        .await
832        .unwrap()
833        .unwrap();
834        assert_eq!(final_response.tool_calls.len(), 1);
835        assert!(matches!(
836            final_response.provider_continuation,
837            Some(ProviderContinuation::MetaResponses { .. })
838        ));
839
840        let mut kinds = Vec::new();
841        while let Ok(event) = rx.try_recv() {
842            kinds.push(match event {
843                StreamEvent::Reasoning(_) => "reasoning",
844                StreamEvent::Text(_) => "text",
845                StreamEvent::ToolCall(_) => "tool",
846                StreamEvent::Done { .. } => "done",
847                StreamEvent::Status(_) => "status",
848            });
849        }
850        assert_eq!(kinds, vec!["reasoning", "text", "tool", "done"]);
851    }
852
853    #[test]
854    fn failed_event_is_redacted_and_structured() {
855        let error = meta_failure(&json!({
856            "type": "response.failed",
857            "response": {
858                "error": {
859                    "code": "bad_request",
860                    "message": "Authorization: Bearer abcdef123456ghijkl"
861                }
862            }
863        }));
864        let rendered = error.to_string();
865        assert!(rendered.contains("bad_request"));
866        assert!(rendered.contains("[REDACTED]"));
867        assert!(!rendered.contains("abcdef123456ghijkl"));
868    }
869}