use super::*;
mod tests_env {
use super::*;
use std::env;
fn save_and_set(key: &str, value: &str) -> Option<String> {
let old = env::var(key).ok();
env::set_var(key, value);
old
}
fn restore(key: &str, old: Option<String>) {
match old {
Some(v) => env::set_var(key, v),
None => env::remove_var(key),
}
}
#[test]
fn test_from_env_result_ok_when_key_set() {
let _lock = crate::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let old = save_and_set("OPENAI_API_KEY", "test-key-123");
assert!(OpenAIChat::from_env_result().is_ok());
restore("OPENAI_API_KEY", old);
}
#[test]
fn test_from_env_result_err_when_key_missing() {
let _lock = crate::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let old = env::var("OPENAI_API_KEY").ok();
env::remove_var("OPENAI_API_KEY");
assert!(OpenAIChat::from_env_result().is_err());
restore("OPENAI_API_KEY", old);
}
}
mod tests_q3_q4 {
use super::*;
fn message(content: Option<&str>, reasoning: Option<&str>) -> OpenAIMessage {
OpenAIMessage {
role: "assistant".to_string(),
content: content.map(|s| s.to_string()),
reasoning_content: reasoning.map(|s| s.to_string()),
tool_calls: None,
}
}
#[test]
fn test_llm_result_keeps_content_when_non_empty() {
let msg = message(Some("Hello"), Some("hidden chain-of-thought"));
let result = OpenAIChat::llm_result_from_message(
&msg,
"gpt-test".to_string(),
Some(OpenAIUsage {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
}),
);
assert_eq!(result.content, "Hello");
assert_eq!(
result.thinking_content.as_deref(),
Some("hidden chain-of-thought")
);
assert_eq!(result.model, "gpt-test");
let usage = result.token_usage.unwrap();
assert_eq!(usage.prompt_tokens, 10);
assert_eq!(usage.completion_tokens, 20);
assert_eq!(usage.total_tokens, 30);
}
#[test]
fn test_llm_result_reasoning_does_not_leak_into_content() {
let msg = message(Some(""), Some("reasoning only"));
let result = OpenAIChat::llm_result_from_message(&msg, "gpt-test".to_string(), None);
assert_eq!(result.content, "");
assert_eq!(result.thinking_content.as_deref(), Some("reasoning only"));
}
#[test]
fn test_llm_result_empty_content_no_thinking() {
let msg = message(None, Some(""));
let result = OpenAIChat::llm_result_from_message(&msg, "gpt-test".to_string(), None);
assert_eq!(result.content, "");
assert!(result.thinking_content.is_none());
}
#[tokio::test]
async fn test_aggregate_stream_concatenates_tokens_in_order() {
let stream: Pin<Box<dyn Stream<Item = Result<StreamChunk, OpenAIError>> + Send>> =
Box::pin(futures_util::stream::iter(vec![
Ok(StreamChunk::new("Hello")),
Ok(StreamChunk::new(", ")),
Ok(StreamChunk::new("world")),
]));
let (content, token_usage, tool_calls) =
OpenAIChat::aggregate_stream(stream).await.unwrap();
assert_eq!(content, "Hello, world");
assert!(token_usage.is_none());
assert!(tool_calls.is_none());
}
#[tokio::test]
async fn test_aggregate_stream_carries_terminal_usage_and_tool_calls() {
let usage_chunk = StreamChunk {
text: String::new(),
token_usage: Some(TokenUsage {
prompt_tokens: 3,
completion_tokens: 5,
total_tokens: 8,
}),
tool_calls: Some(vec![lc_core::tools::ToolCall::builder("call_1")
.name("get_weather")
.arguments(r#"{"city":"beijing"}"#)
.build()]),
};
let stream: Pin<Box<dyn Stream<Item = Result<StreamChunk, OpenAIError>> + Send>> =
Box::pin(futures_util::stream::iter(vec![
Ok(StreamChunk::new("Hello")),
Ok(StreamChunk::new(" world")),
Ok(usage_chunk),
]));
let (content, token_usage, tool_calls) =
OpenAIChat::aggregate_stream(stream).await.unwrap();
assert_eq!(content, "Hello world");
let usage = token_usage.expect("usage carried through");
assert_eq!(usage.total_tokens, 8);
let calls = tool_calls.expect("tool_calls carried through");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name(), "get_weather");
}
#[tokio::test]
async fn test_aggregate_stream_stops_on_error() {
let stream: Pin<Box<dyn Stream<Item = Result<StreamChunk, OpenAIError>> + Send>> =
Box::pin(futures_util::stream::iter(vec![
Ok(StreamChunk::new("Hello")),
Err(OpenAIError::Api("boom".to_string())),
Ok(StreamChunk::new("never")),
]));
let err = OpenAIChat::aggregate_stream(stream).await.unwrap_err();
assert!(matches!(err, OpenAIError::Api(_)));
}
}
mod tests_streaming_tool_calls {
use super::*;
use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn spawn_sse_server(sse_body: &'static str) -> String {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut header = Vec::new();
let mut byte = [0u8; 1];
while header.len() < 64 * 1024 {
if socket.read_exact(&mut byte).await.is_err() {
return;
}
header.push(byte[0]);
if header.ends_with(b"\r\n\r\n") {
break;
}
}
let header_str = String::from_utf8_lossy(&header).to_lowercase();
let content_length: usize = header_str
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
let mut body = vec![0u8; content_length];
if content_length > 0 && socket.read_exact(&mut body).await.is_err() {
return;
}
let response =
format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n{sse_body}");
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn stream_chat_accumulates_fragmented_tool_calls() {
let sse_body = "\
data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"beij\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ing\\\"}\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt\",\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":8,\"total_tokens\":18}}\n\n\
data: [DONE]\n\n";
let base_url = spawn_sse_server(sse_body).await;
let chat =
OpenAIChat::new(OpenAIConfig::new("test_key").with_base_url(format!("{base_url}/v1")));
let mut stream = chat
.stream_chat_internal(vec![Message::human("weather in beijing")])
.await
.unwrap();
let mut terminal: Option<StreamChunk> = None;
while let Some(item) = stream.next().await {
let chunk = item.expect("chunk ok");
if chunk.tool_calls.is_some() {
terminal = Some(chunk);
}
}
let final_chunk = terminal.expect("terminal chunk carries tool_calls");
let calls = final_chunk.tool_calls.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name(), "get_weather");
assert_eq!(
calls[0].arguments(),
r#"{"city":"beijing"}"#,
"arguments concatenated across fragments"
);
let usage = final_chunk
.token_usage
.expect("usage on the same terminal chunk");
assert_eq!(usage.total_tokens, 18);
}
#[tokio::test]
async fn stream_chat_flushes_tool_calls_without_usage_chunk() {
let sse_body = "\
data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"add\",\"arguments\":\"{\\\"a\\\":1}\"}}]},\"finish_reason\":null}]}\n\n\
data: [DONE]\n\n";
let base_url = spawn_sse_server(sse_body).await;
let chat =
OpenAIChat::new(OpenAIConfig::new("test_key").with_base_url(format!("{base_url}/v1")));
let mut stream = chat
.stream_chat_internal(vec![Message::human("compute")])
.await
.unwrap();
let mut terminal: Option<StreamChunk> = None;
while let Some(item) = stream.next().await {
let chunk = item.expect("chunk ok");
if chunk.tool_calls.is_some() {
terminal = Some(chunk);
}
}
let final_chunk = terminal.expect("flushed tool-calls chunk");
let calls = final_chunk.tool_calls.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name(), "add");
assert_eq!(calls[0].arguments(), r#"{"a":1}"#);
}
}
mod tests_response_format {
use super::*;
use crate::openai::response_format::ResponseFormat;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
#[allow(dead_code)]
struct Person {
name: String,
age: u32,
}
fn sample_messages() -> Vec<Message> {
vec![Message::human("who are you")]
}
#[test]
fn build_request_body_has_no_response_format_by_default() {
let chat = OpenAIChat::new(OpenAIConfig::new("k"));
let body = chat.build_request_body(sample_messages(), false);
assert!(body.get("response_format").is_none());
}
#[test]
fn build_request_body_includes_json_object_format() {
let chat = OpenAIChat::new(OpenAIConfig::new("k"))
.config
.clone()
.with_response_format(ResponseFormat::JsonObject);
let chat = OpenAIChat::new(chat);
let body = chat.build_request_body(sample_messages(), false);
assert_eq!(body["response_format"]["type"], "json_object");
}
#[test]
fn with_json_schema_output_sets_strict_schema_format() {
let chat = OpenAIChat::new(OpenAIConfig::new("k"));
let method = chat.with_json_schema_output::<Person>();
let body_chat = OpenAIChat {
config: method.config.clone(),
client: chat.client.clone(),
};
let body = body_chat.build_request_body(sample_messages(), false);
let format = &body["response_format"];
assert_eq!(format["type"], "json_schema");
assert_eq!(format["json_schema"]["name"], "output");
assert_eq!(format["json_schema"]["strict"], true);
let schema = &format["json_schema"]["schema"];
assert_eq!(
schema["additionalProperties"], false,
"strict mode requires additionalProperties: false"
);
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
required,
vec!["age", "name"],
"strict mode requires all properties"
);
}
#[test]
fn with_structured_output_keeps_tool_based_path() {
let chat = OpenAIChat::new(OpenAIConfig::new("k"));
let method = chat.with_structured_output::<Person>();
let body_chat = OpenAIChat {
config: method.config.clone(),
client: chat.client.clone(),
};
let body = body_chat.build_request_body(sample_messages(), false);
assert!(
body.get("response_format").is_none(),
"tool-based path must not set response_format"
);
assert_eq!(body["tools"][0]["function"]["strict"], true);
assert_eq!(body["tool_choice"], "auto");
}
}