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 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 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 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 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 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 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}