1use 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 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 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 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 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 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 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 #[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}