use super::*;
use std::collections::HashMap;
use crate::types::{
ContentPart, FinishReason, Message, MessageContent, ReasoningEffort, ResponseFormat,
ServiceTier, ThinkingConfig, ThinkingType, TokenUsage, ToolChoice, ToolDefinition,
};
#[test]
fn test_build_request_body_basic() {
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("gpt-4o", vec![Message::user("Hello")]);
let body = adapter.build_request_body(&request, false).unwrap();
assert_eq!(body["model"], "gpt-4o");
assert_eq!(body["stream"], false);
assert!(body["messages"].is_array());
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"], "Hello");
}
#[test]
fn test_build_request_body_default_model() {
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest {
model: None,
messages: vec![Message::user("Hi")],
..Default::default()
};
let body = adapter.build_request_body(&request, false).unwrap();
assert_eq!(body["model"], "gpt-4o");
}
#[test]
fn test_build_request_body_streaming() {
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("gpt-4o", vec![Message::user("Hello")]);
let body = adapter.build_request_body(&request, true).unwrap();
assert_eq!(body["stream"], true);
}
#[test]
fn test_build_request_body_with_all_options() {
let adapter = OpenAiChatAdapter::new();
let mut request = CompletionRequest::new("gpt-4o", vec![Message::user("Hello")]);
request.temperature = Some(0.5);
request.max_tokens = Some(100);
request.max_completion_tokens = Some(200);
request.top_p = Some(0.5);
request.top_k = Some(50);
request.frequency_penalty = Some(0.5);
request.presence_penalty = Some(0.5);
request.seed = Some(42);
request.reasoning_effort = Some(ReasoningEffort::Medium);
request.logprobs = Some(true);
request.stop = Some(vec!["\n".into(), "END".into()]);
request.stream_include_usage = Some(true);
let body = adapter.build_request_body(&request, true).unwrap();
assert_eq!(body["temperature"], 0.5);
assert_eq!(body["max_tokens"], 100);
assert_eq!(body["max_completion_tokens"], 200);
assert_eq!(body["top_p"], 0.5);
assert_eq!(body["top_k"], 50);
assert_eq!(body["frequency_penalty"], 0.5);
assert_eq!(body["presence_penalty"], 0.5);
assert_eq!(body["seed"], 42);
assert_eq!(body["reasoning_effort"], "medium");
assert_eq!(body["logprobs"], true);
assert_eq!(body["stop"], serde_json::json!(["\n", "END"]));
assert_eq!(body["stream_options"]["include_usage"], true);
let body_no_stream = adapter.build_request_body(&request, false).unwrap();
assert!(
body_no_stream.get("stream_options").is_none(),
"stream_options must be omitted when stream=false, got {body_no_stream}"
);
}
#[test]
fn test_stream_options_only_when_streaming() {
let adapter = OpenAiChatAdapter::new();
let mut request = CompletionRequest::new("deepseek-v4-pro", vec![Message::user("Hello")]);
request.stream_include_usage = Some(true);
let streamed = adapter.build_request_body(&request, true).unwrap();
assert_eq!(streamed["stream"], true);
assert_eq!(streamed["stream_options"]["include_usage"], true);
let non_stream = adapter.build_request_body(&request, false).unwrap();
assert_eq!(non_stream["stream"], false);
assert!(
non_stream.get("stream_options").is_none(),
"DeepSeek rejects stream_options without stream=true"
);
}
#[test]
fn test_build_request_body_with_tools() {
let adapter = OpenAiChatAdapter::new();
let mut request = CompletionRequest::new("gpt-4o", vec![Message::user("Search?")]);
request.tools = Some(vec![ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
strict: None,
}]);
request.tool_choice = Some(ToolChoice::Auto);
let body = adapter.build_request_body(&request, false).unwrap();
assert!(body["tools"].is_array());
assert_eq!(body["tools"][0]["type"], "function");
assert_eq!(body["tools"][0]["function"]["name"], "search");
assert_eq!(body["tool_choice"], "auto");
}
#[test]
fn test_build_request_body_with_response_format() {
let adapter = OpenAiChatAdapter::new();
let mut request = CompletionRequest::new("gpt-4o", vec![Message::user("JSON pls")]);
request.response_format = Some(ResponseFormat::Json);
let body = adapter.build_request_body(&request, false).unwrap();
assert_eq!(body["response_format"]["type"], "json_object");
}
#[test]
fn test_build_request_body_with_multipart_vision() {
let adapter = OpenAiChatAdapter::new();
let parts = vec![
ContentPart::Text { text: "What's in this image?".into() },
ContentPart::ImageUrl { url: "https://example.com/img.png".into(), detail: None },
];
let request = CompletionRequest::new(
"gpt-4o",
vec![Message::User { content: MessageContent::MultiPart(parts) }],
);
let body = adapter.build_request_body(&request, false).unwrap();
let msg = &body["messages"][0];
assert_eq!(msg["role"], "user");
assert!(msg["content"].is_array());
assert_eq!(msg["content"][0]["type"], "text");
assert_eq!(msg["content"][0]["text"], "What's in this image?");
assert_eq!(msg["content"][1]["type"], "image_url");
assert_eq!(msg["content"][1]["image_url"]["url"], "https://example.com/img.png");
}
#[test]
fn test_build_auth_headers_bearer() {
let adapter = OpenAiChatAdapter::new();
let auth = AuthMethod::Bearer { token: "sk-test".into() };
let headers = adapter.build_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0], ("Authorization".into(), "Bearer sk-test".into()));
}
#[test]
fn test_build_auth_headers_api_key() {
let adapter = OpenAiChatAdapter::new();
let auth = AuthMethod::ApiKey { header_name: "x-api-key".into(), key: "my-key".into() };
let headers = adapter.build_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0], ("x-api-key".into(), "my-key".into()));
}
#[test]
fn test_build_auth_headers_none() {
let adapter = OpenAiChatAdapter::new();
let headers = adapter.build_auth_headers(&AuthMethod::None);
assert!(headers.is_empty());
}
#[test]
fn test_parse_response_basic() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello!"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30
}
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.content.as_deref(), Some("Hello!"));
assert_eq!(resp.model, "gpt-4o");
assert_eq!(resp.finish_reason, FinishReason::Stop);
assert_eq!(resp.usage.prompt_tokens, 10);
assert_eq!(resp.usage.completion_tokens, 20);
assert_eq!(resp.usage.total_tokens, 30);
assert!(resp.tool_calls.is_empty());
assert!(resp.thinking.is_none());
assert_eq!(resp.id.as_deref(), Some("chatcmpl-123"));
assert_eq!(resp.created, Some(1234567890));
}
#[test]
fn test_parse_response_with_tool_calls() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-456",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc",
"type": "function",
"function": {
"name": "search",
"arguments": "{\"query\": \"hello\"}"
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
});
let resp = adapter.parse_response(&json).unwrap();
assert!(resp.content.is_none());
assert_eq!(resp.finish_reason, FinishReason::ToolCall);
assert_eq!(resp.tool_calls.len(), 1);
assert_eq!(resp.tool_calls[0].id, "call_abc");
assert_eq!(resp.tool_calls[0].function_name, "search");
assert_eq!(resp.tool_calls[0].arguments["query"], "hello");
}
#[test]
fn test_parse_response_length_finish() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-789",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Partial" },
"finish_reason": "length"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 100, "total_tokens": 105 }
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::MaxTokens);
assert_eq!(resp.content.as_deref(), Some("Partial"));
}
#[test]
fn test_parse_response_content_filter() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": null },
"finish_reason": "content_filter"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 0, "total_tokens": 5 }
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::ContentFilter);
}
#[test]
fn test_parse_response_unknown_finish_reason_degrades_to_stop() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hi" },
"finish_reason": "eos"
}],
"usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::Stop);
}
#[test]
fn test_parse_response_missing_finish_reason_defaults_to_stop() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hi" }
}],
"usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::Stop);
}
#[test]
fn test_parse_response_missing_choices_error() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o"
});
let err = adapter.parse_response(&json).unwrap_err();
assert!(matches!(err, ProviderError::Format(_)));
assert!(err.to_string().contains("choices[0]"));
}
#[test]
fn test_parse_response_missing_message_error() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }
});
let err = adapter.parse_response(&json).unwrap_err();
assert!(matches!(err, ProviderError::Format(_)));
assert!(err.to_string().contains("message"));
}
#[test]
fn test_parse_response_with_cached_tokens() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hi" },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"prompt_tokens_details": {
"cached_tokens": 30
}
}
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.usage.cached_tokens, Some(30));
}
#[test]
fn test_parse_response_no_usage_defaults_to_zero() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hi" },
"finish_reason": "stop"
}]
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.usage.prompt_tokens, 0);
assert_eq!(resp.usage.completion_tokens, 0);
assert_eq!(resp.usage.total_tokens, 0);
}
#[test]
fn test_parse_sse_content_delta() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(event, Some(StreamEvent::ContentDelta { delta: "Hello".into() }));
}
#[test]
fn test_parse_sse_multiple_content_deltas() {
let adapter = OpenAiChatAdapter::new();
let data1 = r#"{"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let data2 =
r#"{"choices":[{"index":0,"delta":{"content":" World"},"finish_reason":null}]}"#;
assert_eq!(
adapter.parse_sse_event(data1).unwrap(),
Some(StreamEvent::ContentDelta { delta: "Hello".into() })
);
assert_eq!(
adapter.parse_sse_event(data2).unwrap(),
Some(StreamEvent::ContentDelta { delta: " World".into() })
);
}
#[test]
fn test_parse_sse_empty_content_ignored() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert!(event.is_none());
}
#[test]
fn test_parse_sse_tool_call_delta_with_id_and_name() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::ToolCallDelta {
index: 0,
id: Some("call_abc".into()),
function_name: Some("search".into()),
arguments_delta: "".into(),
})
);
}
#[test]
fn test_parse_sse_tool_call_delta_arguments_only() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::ToolCallDelta {
index: 0,
id: None,
function_name: None,
arguments_delta: "{\"query\":".into(),
})
);
}
#[test]
fn test_parse_sse_finish_reason_emits_done() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done {
finish_reason: FinishReason::Stop,
usage: Some(TokenUsage::new(10, 5)),
})
);
}
#[test]
fn test_parse_sse_finish_reason_without_usage() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done { finish_reason: FinishReason::ToolCall, usage: None })
);
}
#[test]
fn test_parse_sse_finish_reason_length() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"length"}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done { finish_reason: FinishReason::MaxTokens, usage: None })
);
}
#[test]
fn test_parse_sse_finish_reason_content_filter() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"content_filter"}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done { finish_reason: FinishReason::ContentFilter, usage: None })
);
}
#[test]
fn test_parse_sse_unknown_finish_reason_degrades_to_stop() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"eos"}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done { finish_reason: FinishReason::Stop, usage: None })
);
}
#[test]
fn test_parse_sse_done_signal_ignored() {
let adapter = OpenAiChatAdapter::new();
let event = adapter.parse_sse_event("[DONE]").unwrap();
assert!(event.is_none());
}
#[test]
fn test_parse_sse_standalone_usage_event() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(event, Some(StreamEvent::Usage { usage: TokenUsage::new(10, 5) }));
}
#[test]
fn test_parse_sse_invalid_json_returns_error() {
let adapter = OpenAiChatAdapter::new();
let result = adapter.parse_sse_event("not valid json");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ProviderError::Format(_)));
}
#[test]
fn test_parse_sse_empty_event_ignored() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert!(event.is_none());
}
#[test]
fn test_parse_sse_whitespace_only_content_ignored() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(event, Some(StreamEvent::ContentDelta { delta: " ".into() }));
}
#[test]
fn test_full_streaming_sequence_exactly_one_done() {
let adapter = OpenAiChatAdapter::new();
let chunks = vec![
r#"{"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{"content":" World"},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}"#,
"[DONE]",
];
let mut events: Vec<StreamEvent> = Vec::new();
for chunk in &chunks {
if let Some(event) = adapter.parse_sse_event(chunk).unwrap() {
events.push(event);
}
}
assert_eq!(events.len(), 3, "expected exactly 3 events: 2 deltas + 1 done");
let done_count = events.iter().filter(|e| matches!(e, StreamEvent::Done { .. })).count();
assert_eq!(done_count, 1, "expected exactly one Done event");
match &events[2] {
StreamEvent::Done { finish_reason, usage } => {
assert_eq!(*finish_reason, FinishReason::Stop);
assert_eq!(usage.as_ref().unwrap().total_tokens, 8);
}
other => panic!("expected Done, got {:?}", other),
}
}
#[test]
fn test_full_streaming_with_tool_calls() {
let adapter = OpenAiChatAdapter::new();
let chunks = vec![
r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"hello\"}"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#,
"[DONE]",
];
let mut events: Vec<StreamEvent> = Vec::new();
for chunk in &chunks {
if let Some(event) = adapter.parse_sse_event(chunk).unwrap() {
events.push(event);
}
}
assert_eq!(events.len(), 4, "expected 4 events: 3 tool call deltas + 1 done");
match &events[0] {
StreamEvent::ToolCallDelta { index, id, function_name, arguments_delta } => {
assert_eq!(*index, 0);
assert_eq!(id.as_deref(), Some("call_1"));
assert_eq!(function_name.as_deref(), Some("search"));
assert_eq!(arguments_delta, "");
}
other => panic!("expected ToolCallDelta, got {:?}", other),
}
match &events[1] {
StreamEvent::ToolCallDelta { index, id, function_name, arguments_delta } => {
assert_eq!(*index, 0);
assert!(id.is_none());
assert!(function_name.is_none());
assert_eq!(arguments_delta, "{\"query\":");
}
other => panic!("expected ToolCallDelta, got {:?}", other),
}
match &events[2] {
StreamEvent::ToolCallDelta { arguments_delta, .. } => {
assert_eq!(arguments_delta, "\"hello\"}");
}
other => panic!("expected ToolCallDelta, got {:?}", other),
}
match &events[3] {
StreamEvent::Done { finish_reason, usage } => {
assert_eq!(*finish_reason, FinishReason::ToolCall);
assert!(usage.is_none());
}
other => panic!("expected Done, got {:?}", other),
}
let done_count = events.iter().filter(|e| matches!(e, StreamEvent::Done { .. })).count();
assert_eq!(done_count, 1, "expected exactly one Done event");
}
#[test]
fn test_parse_sse_chunk_fragmentation() {
let adapter = OpenAiChatAdapter::new();
let full_json =
r#"{"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let event = adapter.parse_sse_event(full_json).unwrap();
assert_eq!(event, Some(StreamEvent::ContentDelta { delta: "Hello".into() }));
}
#[tokio::test]
async fn test_complete_streaming_via_wiremock() {
let mock_server = wiremock::MockServer::start().await;
let sse_events = &[
r#"{"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{"content":" World"},"finish_reason":null}]}"#,
r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}"#,
];
let body_str = {
let mut b = String::new();
for ev in sse_events {
b.push_str("data: ");
b.push_str(ev);
b.push_str("\n\n");
}
b.push_str("data: [DONE]\n\n");
b
};
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(body_str))
.mount(&mock_server)
.await;
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("gpt-4o", vec![Message::user("Hi")]);
let req_body = adapter.build_request_body(&request, true).unwrap();
let auth = AuthMethod::Bearer { token: "sk-test".into() };
let headers = adapter.build_auth_headers(&auth);
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let resp =
crate::http::build_request(&client, &url, req_body, headers, None).await.unwrap();
assert!(resp.status().is_success());
let stream = crate::http::create_sse_stream(resp, None);
use futures::StreamExt;
let sse_lines: Vec<String> =
stream.filter_map(|r| futures::future::ready(r.ok())).collect().await;
let mut events: Vec<StreamEvent> = Vec::new();
for line in &sse_lines {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
continue;
}
if let Ok(Some(event)) = adapter.parse_sse_event(data) {
events.push(event);
}
}
}
assert_eq!(
events.len(),
3,
"expected 3 events, got {}: {:?} (lines: {:?})",
events.len(),
events,
sse_lines
);
assert_eq!(events[0], StreamEvent::ContentDelta { delta: "Hello".into() });
assert_eq!(events[1], StreamEvent::ContentDelta { delta: " World".into() });
match &events[2] {
StreamEvent::Done { finish_reason, usage } => {
assert_eq!(*finish_reason, FinishReason::Stop);
let u = usage.as_ref().expect("expected usage in Done");
assert_eq!(u.total_tokens, 8);
}
other => panic!("expected Done, got {:?}", other),
}
let done_count = events.iter().filter(|e| matches!(e, StreamEvent::Done { .. })).count();
assert_eq!(done_count, 1, "expected exactly one Done event");
}
#[tokio::test]
async fn test_error_401_via_wiremock() {
let mock_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(
wiremock::ResponseTemplate::new(401)
.set_body_string(r#"{"error":{"message":"Invalid API key"}}"#),
)
.mount(&mock_server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let body = serde_json::json!({"model": "gpt-4o", "messages": []});
let resp = crate::http::build_request(&client, &url, body, vec![], None).await.unwrap();
assert_eq!(resp.status().as_u16(), 401);
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let err_body = resp.text().await.unwrap_or_default();
let err = crate::http::handle_error_response(status, &headers, &err_body);
assert!(matches!(err, ProviderError::Auth(m) if m.contains("Unauthorized")));
}
#[tokio::test]
async fn test_error_429_via_wiremock() {
let mock_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(
wiremock::ResponseTemplate::new(429)
.insert_header("retry-after", "5")
.set_body_string("Rate limit exceeded"),
)
.mount(&mock_server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let body = serde_json::json!({"model": "gpt-4o", "messages": []});
let resp = crate::http::build_request(&client, &url, body, vec![], None).await.unwrap();
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let err_body = resp.text().await.unwrap_or_default();
let err = crate::http::handle_error_response(status, &headers, &err_body);
assert!(matches!(err, ProviderError::RateLimit { .. }));
}
#[tokio::test]
async fn test_error_500_via_wiremock() {
let mock_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(
wiremock::ResponseTemplate::new(500)
.set_body_string(r#"{"error":{"message":"Internal error"}}"#),
)
.mount(&mock_server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let body = serde_json::json!({"model": "gpt-4o", "messages": []});
let resp = crate::http::build_request(&client, &url, body, vec![], None).await.unwrap();
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let err_body = resp.text().await.unwrap_or_default();
let err = crate::http::handle_error_response(status, &headers, &err_body);
assert!(matches!(err, ProviderError::Internal { .. }));
}
#[tokio::test]
async fn test_cancellation_before_stream_processing() {
let adapter = OpenAiChatAdapter::new();
let mut sse_body = String::new();
for i in 0..50 {
sse_body.push_str(&format!(
"data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"chunk {}\"}},\"finish_reason\":null}}]}}\n\n",
i
));
}
sse_body.push_str(
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
);
sse_body.push_str("data: [DONE]\n\n");
let mut events: Vec<StreamEvent> = Vec::new();
for line in sse_body.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if events.len() >= 3 {
break;
}
if let Ok(Some(event)) = adapter.parse_sse_event(data) {
events.push(event);
}
}
}
assert!(events.len() <= 3);
let done_count = events.iter().filter(|e| matches!(e, StreamEvent::Done { .. })).count();
assert_eq!(done_count, 0);
}
#[tokio::test]
async fn test_timeout_during_request() {
let mock_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_string("data: hello\n\n")
.set_delay(std::time::Duration::from_millis(5000)),
)
.mount(&mock_server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let body = serde_json::json!({"model": "gpt-4o", "messages": []});
let result = crate::http::build_request(
&client,
&url,
body,
vec![],
Some(std::time::Duration::from_millis(50)),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, ProviderError::Timeout { .. }),
"expected Timeout error, got {:?}",
err
);
}
#[test]
fn test_build_request_body_with_new_fields() {
let adapter = OpenAiChatAdapter::new();
let mut request = CompletionRequest::new("deepseek-chat", vec![Message::user("Hello")]);
request.parallel_tool_calls = Some(true);
request.user = Some("user-abc".into());
request.store = Some(true);
request.metadata =
Some(HashMap::from([("session_id".into(), Value::String("sess_1".into()))]));
request.service_tier = Some(ServiceTier::Auto);
request.thinking = Some(ThinkingConfig {
thinking_type: ThinkingType::Enabled { budget_tokens: Some(4096) },
display: None,
});
let body = adapter.build_request_body(&request, false).unwrap();
assert_eq!(body["parallel_tool_calls"], true);
assert_eq!(body["user"], "user-abc");
assert_eq!(body["store"], true);
assert_eq!(body["metadata"]["session_id"], "sess_1");
assert_eq!(body["service_tier"], "auto");
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 4096);
}
#[test]
fn test_build_request_body_new_fields_absent_when_none() {
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("gpt-4o", vec![Message::user("Hi")]);
let body = adapter.build_request_body(&request, false).unwrap();
assert!(!body.as_object().unwrap().contains_key("parallel_tool_calls"));
assert!(!body.as_object().unwrap().contains_key("user"));
assert!(!body.as_object().unwrap().contains_key("store"));
assert!(!body.as_object().unwrap().contains_key("metadata"));
assert!(!body.as_object().unwrap().contains_key("service_tier"));
assert!(!body.as_object().unwrap().contains_key("thinking"));
}
#[test]
fn test_parse_response_with_reasoning_content() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-r1",
"object": "chat.completion",
"created": 1700000000,
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Final answer",
"reasoning_content": "Let me think through this step by step..."
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 50,
"total_tokens": 60
}
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.content.as_deref(), Some("Final answer"));
assert_eq!(resp.thinking.as_deref(), Some("Let me think through this step by step..."));
assert_eq!(resp.id.as_deref(), Some("chatcmpl-r1"));
assert_eq!(resp.created, Some(1700000000));
}
#[test]
fn test_parse_response_with_deepseek_usage() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-ds1",
"object": "chat.completion",
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hello" },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"prompt_tokens_details": {
"cached_tokens": 30,
"prompt_cache_hit_tokens": 20,
"prompt_cache_miss_tokens": 80
},
"completion_tokens_details": {
"reasoning_tokens": 15
}
}
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.usage.cached_tokens, Some(30));
assert_eq!(resp.usage.reasoning_tokens, Some(15));
assert_eq!(resp.usage.prompt_cache_hit_tokens, Some(20));
assert_eq!(resp.usage.prompt_cache_miss_tokens, Some(80));
}
#[test]
fn test_parse_response_with_refusal() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-ref",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"refusal": "I cannot answer that question."
},
"finish_reason": "refusal"
}],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 0,
"total_tokens": 5
}
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::Refusal);
assert_eq!(resp.refusal.as_deref(), Some("I cannot answer that question."));
assert!(resp.content.is_none());
}
#[test]
fn test_parse_response_system_fingerprint() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-fp1",
"object": "chat.completion",
"created": 1700000002,
"model": "gpt-4o",
"system_fingerprint": "fp_abc123",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hi" },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.system_fingerprint.as_deref(), Some("fp_abc123"));
}
#[test]
fn test_parse_response_finish_reason_pause_turn() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-pt1",
"object": "chat.completion",
"model": "claude-3-opus",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Let me pause here." },
"finish_reason": "pause_turn"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15 }
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::PauseTurn);
}
#[test]
fn test_parse_response_finish_reason_refusal() {
let adapter = OpenAiChatAdapter::new();
let json = serde_json::json!({
"id": "chatcmpl-ref2",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": null },
"finish_reason": "refusal"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 0, "total_tokens": 5 }
});
let resp = adapter.parse_response(&json).unwrap();
assert_eq!(resp.finish_reason, FinishReason::Refusal);
}
#[test]
fn test_parse_sse_finish_reason_pause_turn() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"pause_turn"}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done { finish_reason: FinishReason::PauseTurn, usage: None })
);
}
#[test]
fn test_parse_sse_finish_reason_refusal() {
let adapter = OpenAiChatAdapter::new();
let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"refusal"}]}"#;
let event = adapter.parse_sse_event(data).unwrap();
assert_eq!(
event,
Some(StreamEvent::Done { finish_reason: FinishReason::Refusal, usage: None })
);
}
#[tokio::test]
async fn test_wiremock_reasoning_content() {
let mock_server = wiremock::MockServer::start().await;
let response_body = serde_json::json!({
"id": "chatcmpl-r1",
"object": "chat.completion",
"created": 1700000000,
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Final answer",
"reasoning_content": "Step-by-step reasoning..."
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 50,
"total_tokens": 60
}
});
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(response_body))
.mount(&mock_server)
.await;
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("deepseek-reasoner", vec![Message::user("Think")]);
let req_body = adapter.build_request_body(&request, false).unwrap();
let auth = AuthMethod::Bearer { token: "sk-test".into() };
let headers = adapter.build_auth_headers(&auth);
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let resp =
crate::http::build_request(&client, &url, req_body, headers, None).await.unwrap();
assert!(resp.status().is_success());
let body: serde_json::Value = resp.json().await.unwrap();
let parsed = adapter.parse_response(&body).unwrap();
assert_eq!(parsed.content.as_deref(), Some("Final answer"));
assert_eq!(parsed.thinking.as_deref(), Some("Step-by-step reasoning..."));
}
#[tokio::test]
async fn test_wiremock_deepseek_usage() {
let mock_server = wiremock::MockServer::start().await;
let response_body = serde_json::json!({
"id": "chatcmpl-ds2",
"object": "chat.completion",
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hello" },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"prompt_tokens_details": {
"cached_tokens": 30,
"prompt_cache_hit_tokens": 20,
"prompt_cache_miss_tokens": 80
},
"completion_tokens_details": {
"reasoning_tokens": 15
}
}
});
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(response_body))
.mount(&mock_server)
.await;
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("deepseek-chat", vec![Message::user("Hi")]);
let req_body = adapter.build_request_body(&request, false).unwrap();
let auth = AuthMethod::Bearer { token: "sk-test".into() };
let headers = adapter.build_auth_headers(&auth);
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let resp =
crate::http::build_request(&client, &url, req_body, headers, None).await.unwrap();
assert!(resp.status().is_success());
let body: serde_json::Value = resp.json().await.unwrap();
let parsed = adapter.parse_response(&body).unwrap();
assert_eq!(parsed.usage.cached_tokens, Some(30));
assert_eq!(parsed.usage.reasoning_tokens, Some(15));
assert_eq!(parsed.usage.prompt_cache_hit_tokens, Some(20));
assert_eq!(parsed.usage.prompt_cache_miss_tokens, Some(80));
}
#[tokio::test]
async fn test_wiremock_sse_finish_reason_refusal() {
let mock_server = wiremock::MockServer::start().await;
let sse_body = concat!(
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"I cannot\"},\"finish_reason\":null}]}\n\n",
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\" answer that.\"},\"finish_reason\":null}]}\n\n",
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"refusal\"}]}\n\n",
"data: [DONE]\n\n",
);
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(sse_body))
.mount(&mock_server)
.await;
let adapter = OpenAiChatAdapter::new();
let request = CompletionRequest::new("gpt-4o", vec![Message::user("Bad question")]);
let req_body = adapter.build_request_body(&request, true).unwrap();
let auth = AuthMethod::Bearer { token: "sk-test".into() };
let headers = adapter.build_auth_headers(&auth);
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", mock_server.uri());
let resp =
crate::http::build_request(&client, &url, req_body, headers, None).await.unwrap();
assert!(resp.status().is_success());
let stream = crate::http::create_sse_stream(resp, None);
use futures::StreamExt;
let sse_lines: Vec<String> =
stream.filter_map(|r| futures::future::ready(r.ok())).collect().await;
let mut events: Vec<StreamEvent> = Vec::new();
for line in &sse_lines {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
continue;
}
if let Ok(Some(event)) = adapter.parse_sse_event(data) {
events.push(event);
}
}
}
assert_eq!(events.len(), 3, "expected 3 events: 2 deltas + 1 done");
assert_eq!(events[0], StreamEvent::ContentDelta { delta: "I cannot".into() });
assert_eq!(events[1], StreamEvent::ContentDelta { delta: " answer that.".into() });
match &events[2] {
StreamEvent::Done { finish_reason, usage } => {
assert_eq!(*finish_reason, FinishReason::Refusal);
assert!(usage.is_none());
}
other => panic!("expected Done, got {:?}", other),
}
}