//! OpenAI provider(Responses + Chat Completions)的录制/回放集成测试。 //! Recorded/replayed integration tests for the OpenAI provider (both //! Responses and Chat Completions). mod common; use focus_core::model::*; use focus_core::provider::{ProviderRequest, StreamEvent}; use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_providers::config::ProviderConfig; use focus_providers::openai::{OpenAiProtocol, OpenAiProvider}; use std::sync::Arc; fn provider(mock: common::MockTransport, protocol: OpenAiProtocol) -> OpenAiProvider { OpenAiProvider::with_transport(ProviderConfig::new("sk-test"), protocol, Arc::new(mock)) } fn request() -> ProviderRequest { ProviderRequest { model: "gpt-4o".into(), system_prompt: "Be concise.".into(), messages: vec![Message::user_text("hi")], tools: focus_json::JsonValue::arr(), max_tokens: Some(512), temperature: None, } } fn final_message(events: &[StreamEvent]) -> &AssistantMessage { events .iter() .find_map(|e| match e { StreamEvent::Done { message } => Some(message), _ => None, }) .expect("done event") } // ---- Chat Completions ---- #[test] fn chat_streams_text_and_usage() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":5}}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); // 事件序列:start → text_start → 两个 text_delta → text_end → done。 // Sequence: start → text_start → two text_deltas → text_end → done. assert!(matches!(events[0], StreamEvent::Start { .. })); assert!(matches!(events[1], StreamEvent::TextStart { .. })); assert!(matches!(events[2], StreamEvent::TextDelta { ref delta, .. } if delta == "Hello")); assert!(matches!(events[3], StreamEvent::TextDelta { ref delta, .. } if delta == " world")); assert!(matches!(events[4], StreamEvent::TextEnd { .. })); let msg = final_message(&events); let text = msg.content[0].as_text().expect("text block"); assert_eq!(text.text, "Hello world"); assert_eq!(msg.stop_reason, StopReason::Stop); assert_eq!(msg.usage.input_tokens, 9); assert_eq!(msg.usage.output_tokens, 5); } #[test] fn chat_streams_interleaved_tool_calls() { let mut mock = common::MockTransport::new(); mock.push_body( // 第一个 chunk 同时打开两个调用;随后参数交错到达。 // First chunk opens both calls; arguments then arrive interleaved. "data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"\"}},{\"index\":1,\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"write\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"b\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.stop_reason, StopReason::ToolUse); assert_eq!(msg.content.len(), 2, "two tool calls"); let tc0 = msg.content[0].as_tool_call().expect("call 0"); let tc1 = msg.content[1].as_tool_call().expect("call 1"); assert_eq!(tc0.name, "read"); assert_eq!(tc0.arguments.get_str("path"), Some("a")); assert_eq!(tc1.name, "write"); assert_eq!(tc1.arguments.get_str("path"), Some("b")); // 交错参数必须归位(回归:见 focus-core reducer 修复)。 // Interleaved arguments must land in the right slots (regression: see the // focus-core reducer fix). assert!(tc0.arguments.get_str("path") == Some("a")); } #[test] fn chat_encodes_error_chunk() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"error\":{\"message\":\"invalid api key\",\"type\":\"authentication_error\"}}\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); match events.last().unwrap() { StreamEvent::Error { error } => { let msg = error.error_message.clone().unwrap_or_default(); assert!(msg.contains("invalid api key"), "got: {}", msg); } other => panic!("expected error event, got {:?}", other), } } // ---- Responses API ---- #[test] fn responses_streams_text() { let mut mock = common::MockTransport::new(); mock.push_body( "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\",\"status\":\"in_progress\"}}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"it1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]}]}}\n\n\ event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"it1\",\"output_index\":0,\"delta\":\"Hi\"}\n\n\ event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"it1\",\"output_index\":0,\"delta\":\" there\"}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":4,\"total_tokens\":16,\"input_tokens_details\":{\"cached_tokens\":3}}}}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); let msg = final_message(&events); let text = msg.content[0].as_text().expect("text block"); assert_eq!(text.text, "Hi there"); assert_eq!(msg.stop_reason, StopReason::Stop); assert_eq!(msg.usage.input_tokens, 12); assert_eq!(msg.usage.output_tokens, 4); assert_eq!(msg.usage.cache_read_tokens, 3); } #[test] fn responses_streams_function_call() { let mut mock = common::MockTransport::new(); mock.push_body( "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"fc1\",\"type\":\"function_call\",\"call_id\":\"call_x\",\"name\":\"read\",\"arguments\":\"\",\"status\":\"in_progress\"}}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc1\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\\\"x\"}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc1\",\"output_index\":0,\"delta\":\"y.txt\\\"}\"}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":6}}}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.content.len(), 1); let tc = msg.content[0].as_tool_call().expect("tool call"); assert_eq!(tc.name, "read"); assert_eq!(tc.arguments.get_str("path"), Some("xy.txt")); } #[test] fn responses_encodes_error() { let mut mock = common::MockTransport::new(); mock.push_body( "event: error\ndata: {\"type\":\"error\",\"code\":\"invalid_request_error\",\"message\":\"bad model\"}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); match events.last().unwrap() { StreamEvent::Error { error } => { let msg = error.error_message.clone().unwrap_or_default(); assert!(msg.contains("bad model"), "got: {}", msg); } other => panic!("expected error event, got {:?}", other), } } #[test] fn request_headers_and_path() { let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &request()); let req = mock.last_request(); assert_eq!(req.host, "api.openai.com"); assert_eq!(req.path, "/v1/chat/completions"); let headers: Vec<(String, String)> = req.headers.clone(); assert!(headers .iter() .any(|(k, v)| { k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test" })); } /// Chat Completions 请求体翻译(回归:原为内联私有函数测试)。 /// Chat Completions request-body translation (regression: was an inline /// private-fn test). #[test] fn chat_request_body_translation() { let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &rich_request()); let req = mock.last_request(); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); let messages = body.get_arr("messages").unwrap(); // system 提示作为第一条消息。 // The system prompt becomes the first message. assert_eq!(messages[0].get_str("role"), Some("system")); assert_eq!(messages[0].get_str("content"), Some("Be concise.")); // assistant 携带 tool_calls(arguments 为 JSON 字符串)。 // Assistant carries tool_calls (arguments as a JSON string). let assistant = &messages[2]; let calls = assistant.get_arr("tool_calls").unwrap(); assert_eq!(calls[0].get_str("id"), Some("call_abc")); let func = calls[0].get("function").unwrap(); assert_eq!(func.get_str("name"), Some("echo")); assert_eq!(func.get_str("arguments"), Some(r#"{"text":"x"}"#)); // tool 消息带 tool_call_id。 // Tool message carries tool_call_id. assert_eq!(messages[3].get_str("role"), Some("tool")); assert_eq!(messages[3].get_str("tool_call_id"), Some("call_abc")); // tools 被翻译成 function 格式。 // Tools translated into the function format. let tools = body.get_arr("tools").unwrap(); assert_eq!(tools[0].get_str("type"), Some("function")); let f = tools[0].get("function").unwrap(); assert_eq!(f.get_str("name"), Some("echo")); assert!(f.get("parameters").is_some()); assert_eq!(body.get_bool("stream"), Some(true)); assert_eq!(body.get_num("max_completion_tokens"), Some(512.0)); } /// Responses API 请求体翻译(回归:原为内联私有函数测试)。 /// Responses API request-body translation (regression: was an inline /// private-fn test). #[test] fn responses_request_body_translation() { let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::Responses); let _ = common::collect(&provider, &rich_request()); let req = mock.last_request(); assert_eq!(req.path, "/v1/responses"); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); assert_eq!(body.get_str("instructions"), Some("Be concise.")); let input = body.get_arr("input").unwrap(); // 顺序:user → assistant(role) → function_call → function_call_output。 // Order: user → assistant(role) → function_call → function_call_output. assert_eq!(input[0].get_str("role"), Some("user")); assert_eq!(input[1].get_str("role"), Some("assistant")); assert_eq!(input[2].get_str("type"), Some("function_call")); assert_eq!(input[2].get_str("call_id"), Some("call_abc")); assert_eq!(input[3].get_str("type"), Some("function_call_output")); assert_eq!(input[3].get_str("call_id"), Some("call_abc")); let tools = body.get_arr("tools").unwrap(); assert_eq!(tools[0].get_str("name"), Some("echo")); assert_eq!(body.get_num("max_output_tokens"), Some(512.0)); } /// finish_reason 映射:length → Length(回归:原为内联私有函数测试)。 /// Finish-reason mapping: length → Length (regression: was an inline /// private-fn test). #[test] fn maps_chat_length_finish_reason() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"length\"}]}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.stop_reason, StopReason::Length); } /// 一个携带工具调用与工具结果的更完整请求。 /// A richer request carrying a tool call and its result. fn rich_request() -> ProviderRequest { #[derive(Debug)] struct EchoTool; impl Tool for EchoTool { fn name(&self) -> &str { "echo" } fn description(&self) -> &str { "echoes text" } fn parameters(&self) -> focus_json::JsonValue { let mut o = focus_json::JsonValue::obj(); o.insert("type", "object".into()).ok(); let mut props = focus_json::JsonValue::obj(); props .insert("text", focus_json::JsonValue::Str("the text".into())) .ok(); o.insert("properties", props).ok(); o } fn effects(&self) -> ToolEffects { ToolEffects::READ } fn execute( &self, _id: &str, _args: &focus_json::JsonValue, _u: Option<&ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } ProviderRequest { model: "gpt-4o".into(), system_prompt: "Be concise.".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ContentBlock::ToolCall(ToolCall { id: "call_abc".into(), name: "echo".into(), arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), })], model: "gpt-4o".into(), usage: Usage::default(), stop_reason: StopReason::ToolUse, error_message: None, timestamp: 0, }), Message::ToolResult(ToolResultMessage { tool_call_id: "call_abc".into(), tool_name: "echo".into(), content: vec![ContentBlock::text("ok")], details: focus_json::JsonValue::obj(), is_error: false, timestamp: 0, }), ], tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(), max_tokens: Some(512), temperature: Some(0.2), } } /// DeepSeek 等推理 API:思考内容必须作为 reasoning_content 回传(回归)。 /// Reasoning APIs (e.g. DeepSeek): thinking must be echoed back as /// reasoning_content (regression). #[test] fn chat_replays_reasoning_content() { use focus_core::tool::{ToolEffects, ToolRegistry}; #[derive(Debug)] struct NoopTool; impl Tool for NoopTool { fn name(&self) -> &str { "noop" } fn description(&self) -> &str { "does nothing" } fn parameters(&self) -> focus_json::JsonValue { focus_json::JsonValue::obj() } fn effects(&self) -> ToolEffects { ToolEffects::NONE } fn execute( &self, _id: &str, _args: &focus_json::JsonValue, _u: Option<&ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } let request = ProviderRequest { model: "deepseek-v4-flash".into(), system_prompt: "sys".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ ContentBlock::Thinking(ThinkingContent { thinking: "让我先分析一下".into(), signature: None, redacted: false, }), ContentBlock::text("让我看看"), ContentBlock::ToolCall(ToolCall { id: "call_x".into(), name: "shell".into(), arguments: focus_json::parse(r#"{"command":"ls"}"#).unwrap(), }), ], model: "deepseek-v4-flash".into(), usage: Usage::default(), stop_reason: StopReason::ToolUse, error_message: None, timestamp: 0, }), Message::ToolResult(ToolResultMessage { tool_call_id: "call_x".into(), tool_name: "shell".into(), content: vec![ContentBlock::text("ok")], details: focus_json::JsonValue::obj(), is_error: false, timestamp: 0, }), ], tools: ToolRegistry::with(Box::new(NoopTool)).tool_definitions(), max_tokens: Some(512), temperature: None, }; let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &request); let req = mock.last_request(); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); let messages = body.get_arr("messages").unwrap(); // [0] = system,[1] = user,[2] = assistant。 // [0] = system, [1] = user, [2] = assistant. let assistant = &messages[2]; // 思考内容作为 reasoning_content 回传(content 只含文本)。 // Thinking echoed as reasoning_content (content holds only text). assert_eq!( assistant.get_str("reasoning_content"), Some("让我先分析一下") ); assert_eq!(assistant.get_str("content"), Some("让我看看")); // 工具调用照常回传。 // Tool calls are still replayed. assert!(assistant.get_arr("tool_calls").is_some()); }