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