#![ cfg( feature = "enabled" ) ]
use api_openai_compatible::
{
Message, Role, ToolCall, FunctionCall,
ChatCompletionRequest, ChatCompletionResponse,
};
#[ test ]
fn message_user_constructor_serializes_correctly()
{
let msg = Message::user( "What is the capital of France?" );
let json = serde_json::to_string( &msg ).expect( "Message must be serializable" );
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "role" ],
"user",
"role field must be the lowercase string \"user\"",
);
assert_eq!(
parsed[ "content" ],
"What is the capital of France?",
"content field must match the constructor argument",
);
assert!(
parsed.get( "tool_calls" ).is_none() || parsed[ "tool_calls" ].is_null(),
"absent optional field tool_calls must be omitted or null",
);
assert!(
!json.contains( "\"tool_calls\"" ),
"tool_calls must be omitted entirely from JSON when None",
);
assert!(
!json.contains( "\"tool_call_id\"" ),
"tool_call_id must be omitted entirely from JSON when None",
);
}
#[ test ]
fn message_system_constructor_serializes_correctly()
{
let msg = Message::system( "You are a helpful assistant." );
let json = serde_json::to_string( &msg ).expect( "Message must be serializable" );
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "role" ],
"system",
"role field must be the lowercase string \"system\"",
);
assert_eq!(
parsed[ "content" ],
"You are a helpful assistant.",
);
}
#[ test ]
fn message_assistant_constructor_serializes_correctly()
{
let msg = Message::assistant( "Paris is the capital of France." );
let json = serde_json::to_string( &msg ).expect( "Message must be serializable" );
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "role" ],
"assistant",
"role field must be the lowercase string \"assistant\"",
);
assert_eq!(
parsed[ "content" ],
"Paris is the capital of France.",
"content field must match the constructor argument",
);
assert!(
!json.contains( "\"tool_call_id\"" ),
"tool_call_id must be omitted from assistant messages",
);
assert!(
!json.contains( "\"tool_calls\"" ),
"tool_calls must be omitted when not set on an assistant message",
);
}
#[ test ]
fn message_tool_constructor_serializes_correctly()
{
let msg = Message::tool( "call_abc123", r#"{"temperature":22}"# );
let json = serde_json::to_string( &msg ).expect( "Message must be serializable" );
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "role" ],
"tool",
"role field must be the lowercase string \"tool\"",
);
assert_eq!(
parsed[ "tool_call_id" ],
"call_abc123",
"tool_call_id must match the constructor argument",
);
assert_eq!(
parsed[ "content" ],
r#"{"temperature":22}"#,
"content must carry the serialised tool result",
);
assert!(
!json.contains( "\"tool_calls\"" ),
"tool_calls must be omitted from tool-result messages",
);
}
#[ test ]
fn message_with_tool_calls_serializes_correctly()
{
let call = ToolCall
{
id : "call_xyz789".to_string(),
tool_type : "function".to_string(),
function : FunctionCall
{
name : "get_weather".to_string(),
arguments : r#"{"location":"Paris"}"#.to_string(),
},
};
let msg = Message
{
role : Role::Assistant,
content : None,
tool_calls : Some( vec![ call ] ),
tool_call_id : None,
};
let json = serde_json::to_string( &msg ).expect( "Message must be serializable" );
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!( parsed[ "role" ], "assistant" );
let calls = parsed[ "tool_calls" ].as_array()
.expect( "tool_calls must be a JSON array when populated" );
assert_eq!( calls.len(), 1, "exactly one tool call must be serialised" );
assert_eq!( calls[ 0 ][ "id" ], "call_xyz789" );
assert_eq!( calls[ 0 ][ "function" ][ "name" ], "get_weather" );
assert!(
!json.contains( "\"content\"" ),
"content must be omitted when None on a tool-calling assistant message",
);
assert!(
!json.contains( "\"tool_call_id\"" ),
"tool_call_id must be omitted from assistant messages",
);
}
#[ test ]
fn tool_call_round_trips_through_serde()
{
let call = ToolCall
{
id : "call_roundtrip".to_string(),
tool_type : "function".to_string(),
function : FunctionCall
{
name : "ping".to_string(),
arguments : "{}".to_string(),
},
};
let json = serde_json::to_string( &call ).expect( "ToolCall must be serializable" );
assert!(
json.contains( "\"type\"" ),
"ToolCall.tool_type must be serialised as JSON key \"type\"; got: {json}",
);
assert!(
!json.contains( "\"tool_type\"" ),
"JSON must NOT contain \"tool_type\" — the rename attribute must be applied; got: {json}",
);
let round_tripped : ToolCall =
serde_json::from_str( &json ).expect( "ToolCall must be deserializable from its own output" );
assert_eq!( round_tripped.id, "call_roundtrip" );
assert_eq!( round_tripped.tool_type, "function" );
assert_eq!( round_tripped.function.name, "ping" );
assert_eq!( round_tripped.function.arguments, "{}" );
}
#[ test ]
fn chat_completion_request_omits_none_fields()
{
let req = ChatCompletionRequest::former()
.model( "gpt-4o".to_string() )
.messages( vec![ Message::user( "Hello!" ) ] )
.form();
let json = serde_json::to_string( &req ).expect( "ChatCompletionRequest must be serializable" );
assert!( json.contains( "\"model\"" ), "model field must be present" );
assert!( json.contains( "\"messages\"" ), "messages field must be present" );
assert!( !json.contains( "\"temperature\"" ), "temperature must be omitted when None" );
assert!( !json.contains( "\"max_tokens\"" ), "max_tokens must be omitted when None" );
assert!( !json.contains( "\"top_p\"" ), "top_p must be omitted when None" );
assert!( !json.contains( "\"frequency_penalty\"" ), "frequency_penalty must be omitted when None" );
assert!( !json.contains( "\"presence_penalty\"" ), "presence_penalty must be omitted when None" );
assert!( !json.contains( "\"stream\"" ), "stream must be omitted when None" );
assert!( !json.contains( "\"tools\"" ), "tools must be omitted when None" );
}
#[ test ]
fn chat_completion_request_stream_set_to_false_appears_in_json()
{
let req = ChatCompletionRequest::former()
.model( "gpt-4o".to_string() )
.messages( vec![ Message::user( "Hello!" ) ] )
.stream( false )
.form();
let json = serde_json::to_string( &req ).expect( "ChatCompletionRequest must be serializable" );
assert!(
json.contains( "\"stream\"" ),
"stream field must appear in JSON when set to Some(false); got: {json}",
);
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "stream" ],
false,
"stream must serialise as the boolean false (not null or missing)",
);
}
#[ test ]
fn chat_completion_response_deserializes_from_fixture()
{
let fixture = r#"{
"id": "chatcmpl-abc123xyz",
"object": "chat.completion",
"created": 1717000000,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Paris is the capital of France."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 14,
"completion_tokens": 8,
"total_tokens": 22
}
}"#;
let resp : ChatCompletionResponse =
serde_json::from_str( fixture ).expect( "fixture must deserialise into ChatCompletionResponse" );
assert_eq!( resp.id, "chatcmpl-abc123xyz" );
assert_eq!( resp.model, "gpt-4o" );
assert_eq!( resp.usage.total_tokens, 22 );
assert_eq!(
resp.choices[ 0 ].message.content.as_deref(),
Some( "Paris is the capital of France." ),
"assistant message content must round-trip correctly",
);
assert_eq!(
resp.choices[ 0 ].finish_reason.as_deref(),
Some( "stop" ),
);
}
#[ test ]
fn response_tolerates_extra_unknown_json_fields()
{
let fixture = r#"{
"id": "chatcmpl-extra",
"object": "chat.completion",
"created": 1717000001,
"model": "gpt-4o",
"system_fingerprint": "fp_abc123",
"x_provider_data": { "latency_ms": 42 },
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello!",
"unknown_future_field": true
},
"finish_reason": "stop",
"logprobs": null
}
],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 3,
"total_tokens": 8,
"completion_tokens_details": { "reasoning_tokens": 0 }
}
}"#;
let resp : ChatCompletionResponse =
serde_json::from_str( fixture )
.expect( "ChatCompletionResponse must deserialise even with unknown extra fields" );
assert_eq!( resp.id, "chatcmpl-extra" );
assert_eq!(
resp.choices[ 0 ].message.content.as_deref(),
Some( "Hello!" ),
);
}
#[ test ]
fn response_with_multiple_choices_deserializes()
{
let fixture = r#"{
"id": "chatcmpl-multi",
"object": "chat.completion",
"created": 1717000002,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Answer A" },
"finish_reason": "stop"
},
{
"index": 1,
"message": { "role": "assistant", "content": "Answer B" },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 6,
"total_tokens": 16
}
}"#;
let resp : ChatCompletionResponse =
serde_json::from_str( fixture ).expect( "multi-choice fixture must deserialise" );
assert_eq!( resp.choices.len(), 2, "both choices must be deserialised" );
assert_eq!( resp.choices[ 0 ].index, 0 );
assert_eq!( resp.choices[ 1 ].index, 1 );
assert_eq!(
resp.choices[ 0 ].message.content.as_deref(),
Some( "Answer A" ),
);
assert_eq!(
resp.choices[ 1 ].message.content.as_deref(),
Some( "Answer B" ),
);
}
#[ test ]
fn role_round_trips_through_serde()
{
let roles =
[
( Role::System, "\"system\"" ),
( Role::User, "\"user\"" ),
( Role::Assistant, "\"assistant\"" ),
( Role::Tool, "\"tool\"" ),
];
for ( role, expected_json ) in roles
{
let serialised = serde_json::to_string( &role )
.unwrap_or_else( |e| panic!( "Role::{role:?} must be serializable: {e}" ) );
assert_eq!(
serialised,
expected_json,
"Role::{role:?} must serialise to {expected_json}",
);
let deserialised : Role = serde_json::from_str( &serialised )
.unwrap_or_else( |e| panic!( "Role::{role:?} must be deserializable from {serialised}: {e}" ) );
assert_eq!(
deserialised,
role,
"Role::{role:?} must survive a serde round-trip",
);
}
}
#[ cfg( feature = "streaming" ) ]
#[ test ]
fn streaming_chunk_deserializes_from_fixture()
{
use api_openai_compatible::ChatCompletionChunk;
let fixture = r#"{
"id": "chatcmpl-stream-abc",
"object": "chat.completion.chunk",
"created": 1717000010,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": { "content": " world" },
"finish_reason": null
}
]
}"#;
let chunk : ChatCompletionChunk =
serde_json::from_str( fixture ).expect( "streaming fixture must deserialise" );
assert_eq!( chunk.id, "chatcmpl-stream-abc" );
assert_eq!( chunk.object, "chat.completion.chunk" );
assert_eq!( chunk.choices.len(), 1 );
assert_eq!(
chunk.choices[ 0 ].delta.content.as_deref(),
Some( " world" ),
"delta content must be extracted correctly from the streaming chunk",
);
assert!(
chunk.choices[ 0 ].finish_reason.is_none(),
"finish_reason must be None for intermediate chunks",
);
}
#[ cfg( feature = "streaming" ) ]
#[ test ]
fn streaming_delta_none_fields_omitted_from_json()
{
use api_openai_compatible::Delta;
let delta = Delta::default();
let json = serde_json::to_string( &delta ).expect( "Delta must be serializable" );
assert_eq!(
json,
"{}",
"Delta with all None fields must serialise to an empty object; got: {json}",
);
}
#[ test ]
fn message_round_trips_through_serde()
{
let original = Message::tool( "call_rt_001", r#"{"value":42}"# );
let json = serde_json::to_string( &original ).expect( "Message must be serializable" );
let restored : Message =
serde_json::from_str( &json ).expect( "Message must be deserializable from its own JSON" );
assert_eq!( restored, original, "Message must survive a serde round-trip unchanged" );
}
#[ test ]
fn message_user_empty_content_serializes_correctly()
{
let msg = Message::user( "" );
let json = serde_json::to_string( &msg ).expect( "Message::user(\"\") must be serializable" );
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!( parsed[ "role" ], "user", "role must be \"user\" for empty-content user message" );
assert_eq!( parsed[ "content" ], "", "content key must be present and equal empty string" );
assert!(
!json.contains( "\"tool_calls\"" ),
"tool_calls must be omitted entirely when None",
);
assert!(
!json.contains( "\"tool_call_id\"" ),
"tool_call_id must be omitted entirely when None",
);
}
#[ test ]
fn function_call_empty_arguments_round_trips()
{
let call = ToolCall
{
id : "call_empty_args".to_string(),
tool_type : "function".to_string(),
function : FunctionCall
{
name : "no_params_fn".to_string(),
arguments : String::new(),
},
};
let json = serde_json::to_string( &call ).expect( "ToolCall with empty arguments must be serializable" );
let restored : ToolCall =
serde_json::from_str( &json ).expect( "ToolCall must be deserializable from its own JSON" );
assert_eq!(
restored.function.arguments,
"",
"Empty arguments string must survive serde round-trip unchanged",
);
}
#[ test ]
fn function_call_complex_arguments_preserved_as_string()
{
let complex_args = r#"{"location":"New York","units":"metric","extra":{"key":"val"}}"#;
let call = ToolCall
{
id : "call_complex".to_string(),
tool_type : "function".to_string(),
function : FunctionCall
{
name : "get_weather".to_string(),
arguments : complex_args.to_string(),
},
};
let json = serde_json::to_string( &call ).expect( "ToolCall must be serializable" );
let restored : ToolCall =
serde_json::from_str( &json ).expect( "ToolCall must be deserializable" );
assert_eq!(
restored.function.arguments,
complex_args,
"Complex JSON arguments must be preserved exactly as a string, not re-parsed",
);
}
#[ test ]
fn chat_completion_request_stream_true_appears_in_json()
{
let req = ChatCompletionRequest::former()
.model( "gpt-4o".to_string() )
.messages( vec![ Message::user( "Hello!" ) ] )
.stream( true )
.form();
let json = serde_json::to_string( &req ).expect( "ChatCompletionRequest must be serializable" );
assert!(
json.contains( "\"stream\"" ),
"stream field must appear in JSON when set to Some(true); got: {json}",
);
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "stream" ],
true,
"stream must serialise as the boolean true; got: {json}",
);
}
#[ test ]
fn chat_completion_request_temperature_appears_when_set()
{
let req = ChatCompletionRequest::former()
.model( "gpt-4o".to_string() )
.messages( vec![ Message::user( "Hi" ) ] )
.temperature( 0.7_f32 )
.form();
let json = serde_json::to_string( &req ).expect( "ChatCompletionRequest must be serializable" );
assert!(
json.contains( "\"temperature\"" ),
"temperature field must appear in JSON when set; got: {json}",
);
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
let t = parsed[ "temperature" ].as_f64()
.expect( "temperature must be a JSON number" );
assert!(
( t - 0.7 ).abs() < 0.01,
"temperature must round-trip to approximately 0.7; got: {t}",
);
}
#[ test ]
fn chat_completion_request_max_tokens_appears_when_set()
{
let req = ChatCompletionRequest::former()
.model( "gpt-4o".to_string() )
.messages( vec![ Message::user( "Hi" ) ] )
.max_tokens( 512_u32 )
.form();
let json = serde_json::to_string( &req ).expect( "ChatCompletionRequest must be serializable" );
assert!(
json.contains( "\"max_tokens\"" ),
"max_tokens field must appear in JSON when set; got: {json}",
);
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
assert_eq!(
parsed[ "max_tokens" ],
512,
"max_tokens must serialise as the integer 512",
);
}
#[ test ]
fn chat_completion_request_tools_array_serializes_with_type_key()
{
use api_openai_compatible::Tool;
let tool = Tool::function(
"get_weather",
"Get current weather",
serde_json::json!({ "type": "object", "properties": {} }),
);
let req = ChatCompletionRequest::former()
.model( "gpt-4o".to_string() )
.messages( vec![ Message::user( "What is the weather?" ) ] )
.tools( vec![ tool ] )
.form();
let json = serde_json::to_string( &req ).expect( "ChatCompletionRequest must be serializable" );
assert!(
json.contains( "\"tools\"" ),
"tools field must appear in JSON when set; got: {json}",
);
let parsed : serde_json::Value =
serde_json::from_str( &json ).expect( "serialised output must be valid JSON" );
let tools = parsed[ "tools" ].as_array()
.expect( "tools must be a JSON array" );
assert_eq!( tools.len(), 1, "exactly one tool must appear in the array" );
assert_eq!(
tools[ 0 ][ "type" ],
"function",
"Tool.tool_type must serialise as JSON key \"type\" with value \"function\"",
);
assert!(
!json.contains( "\"tool_type\"" ),
"JSON must NOT contain \"tool_type\" — the serde rename must be applied; got: {json}",
);
assert_eq!(
tools[ 0 ][ "function" ][ "name" ],
"get_weather",
"function name must be present and correct",
);
assert_eq!(
tools[ 0 ][ "function" ][ "description" ],
"Get current weather",
"function description must be present and correct",
);
}
#[ test ]
fn response_finish_reason_tool_calls_deserializes()
{
let fixture = r#"{
"id": "chatcmpl-toolcall",
"object": "chat.completion",
"created": 1717000100,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_tc001",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"Paris\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 30,
"completion_tokens": 20,
"total_tokens": 50
}
}"#;
let resp : ChatCompletionResponse =
serde_json::from_str( fixture )
.expect( "fixture with finish_reason=tool_calls must deserialise" );
assert_eq!(
resp.choices[ 0 ].finish_reason.as_deref(),
Some( "tool_calls" ),
"finish_reason must be \"tool_calls\" for tool-call responses",
);
let tool_calls = resp.choices[ 0 ].message.tool_calls.as_ref()
.expect( "tool_calls must be present in the assistant message" );
assert_eq!( tool_calls.len(), 1, "exactly one tool call must be deserialised" );
assert_eq!( tool_calls[ 0 ].id, "call_tc001" );
assert_eq!( tool_calls[ 0 ].function.name, "get_weather" );
assert_eq!(
tool_calls[ 0 ].function.arguments,
r#"{"location":"Paris"}"#,
"arguments must be preserved as raw JSON string",
);
}
#[ test ]
fn response_assistant_message_with_tool_calls_deserializes()
{
let fixture = r#"{
"id": "chatcmpl-nested-tc",
"object": "chat.completion",
"created": 1717000200,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_nested_001",
"type": "function",
"function": {
"name": "send_email",
"arguments": "{\"to\":\"alice@example.com\",\"subject\":\"Hello\"}"
}
},
{
"id": "call_nested_002",
"type": "function",
"function": {
"name": "log_event",
"arguments": "{\"event\":\"greeting\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 25,
"completion_tokens": 40,
"total_tokens": 65
}
}"#;
let resp : ChatCompletionResponse =
serde_json::from_str( fixture )
.expect( "response with assistant tool_calls must deserialise" );
let msg = &resp.choices[ 0 ].message;
assert_eq!( msg.content, None, "content must be None when tool_calls are present" );
let tool_calls = msg.tool_calls.as_ref()
.expect( "tool_calls must be present when assistant is calling functions" );
assert_eq!( tool_calls.len(), 2, "both tool calls must be deserialised" );
assert_eq!( tool_calls[ 0 ].id, "call_nested_001" );
assert_eq!( tool_calls[ 0 ].function.name, "send_email" );
assert_eq!( tool_calls[ 1 ].id, "call_nested_002" );
assert_eq!( tool_calls[ 1 ].function.name, "log_event" );
}
#[ cfg( feature = "streaming" ) ]
#[ test ]
fn streaming_chunk_first_with_role_deserializes()
{
use api_openai_compatible::{ ChatCompletionChunk, Role };
let fixture = r#"{
"id": "chatcmpl-stream-first",
"object": "chat.completion.chunk",
"created": 1717000020,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": { "role": "assistant", "content": "" },
"finish_reason": null
}
]
}"#;
let chunk : ChatCompletionChunk =
serde_json::from_str( fixture ).expect( "first chunk with role must deserialise" );
assert_eq!(
chunk.choices[ 0 ].delta.role,
Some( Role::Assistant ),
"first streaming chunk must carry role=assistant in the delta",
);
assert!(
chunk.choices[ 0 ].finish_reason.is_none(),
"finish_reason must be None for the first chunk",
);
}
#[ cfg( feature = "streaming" ) ]
#[ test ]
fn streaming_chunk_last_with_finish_reason_deserializes()
{
use api_openai_compatible::ChatCompletionChunk;
let fixture = r#"{
"id": "chatcmpl-stream-last",
"object": "chat.completion.chunk",
"created": 1717000030,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop"
}
]
}"#;
let chunk : ChatCompletionChunk =
serde_json::from_str( fixture ).expect( "final chunk with finish_reason must deserialise" );
assert_eq!(
chunk.choices[ 0 ].finish_reason.as_deref(),
Some( "stop" ),
"finish_reason must be \"stop\" in the final streaming chunk",
);
assert!(
chunk.choices[ 0 ].delta.content.is_none(),
"delta content must be None in the final empty chunk",
);
assert!(
chunk.choices[ 0 ].delta.role.is_none(),
"delta role must be None in the final empty chunk",
);
}
#[ cfg( feature = "streaming" ) ]
#[ test ]
fn streaming_chunk_round_trips_through_serde()
{
use api_openai_compatible::{ ChatCompletionChunk, ChunkChoice, Delta, Role };
let original = ChatCompletionChunk
{
id : "chatcmpl-rt".to_string(),
object : "chat.completion.chunk".to_string(),
created : 1_717_000_050,
model : "gpt-4o".to_string(),
choices : vec!
[
ChunkChoice
{
index : 0,
delta : Delta
{
role : Some( Role::Assistant ),
content : Some( " Hello".to_string() ),
tool_calls : None,
},
finish_reason : None,
},
],
};
let json = serde_json::to_string( &original ).expect( "ChatCompletionChunk must be serializable" );
let restored : ChatCompletionChunk =
serde_json::from_str( &json )
.expect( "ChatCompletionChunk must be deserializable from its own JSON" );
assert_eq!( restored, original, "ChatCompletionChunk must survive serde round-trip unchanged" );
}