444 lines
19 KiB
Rust
444 lines
19 KiB
Rust
//! 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<ToolResult, focus_core::CoreError> {
|
||
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<ToolResult, focus_core::CoreError> {
|
||
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());
|
||
}
|