use super::*;
use crate::llm::types::{Message, ToolDefinition};
fn make_client() -> OpenAiClient {
OpenAiClient::new("test-key".to_string(), "gpt-test".to_string())
}
struct MockSseHttp {
chunks: Vec<String>,
}
struct PendingSseHttp;
#[async_trait::async_trait]
impl crate::llm::http::HttpClient for MockSseHttp {
async fn post(
&self,
_url: &str,
_headers: Vec<(&str, &str)>,
_body: &serde_json::Value,
_cancel: tokio_util::sync::CancellationToken,
) -> anyhow::Result<crate::llm::http::HttpResponse> {
anyhow::bail!("post is unused in the streaming test")
}
async fn post_streaming(
&self,
_url: &str,
_headers: Vec<(&str, &str)>,
_body: &serde_json::Value,
_cancel: tokio_util::sync::CancellationToken,
) -> anyhow::Result<crate::llm::http::StreamingHttpResponse> {
let items: Vec<anyhow::Result<bytes::Bytes>> = self
.chunks
.iter()
.map(|s| Ok(bytes::Bytes::from(s.clone())))
.collect();
Ok(crate::llm::http::StreamingHttpResponse {
status: 200,
retry_after: None,
byte_stream: Box::pin(futures::stream::iter(items)),
error_body: String::new(),
})
}
}
#[async_trait::async_trait]
impl crate::llm::http::HttpClient for PendingSseHttp {
async fn post(
&self,
_url: &str,
_headers: Vec<(&str, &str)>,
_body: &serde_json::Value,
_cancel: tokio_util::sync::CancellationToken,
) -> anyhow::Result<crate::llm::http::HttpResponse> {
anyhow::bail!("post is unused in the streaming cancellation test")
}
async fn post_streaming(
&self,
_url: &str,
_headers: Vec<(&str, &str)>,
_body: &serde_json::Value,
_cancel: tokio_util::sync::CancellationToken,
) -> anyhow::Result<crate::llm::http::StreamingHttpResponse> {
Ok(crate::llm::http::StreamingHttpResponse {
status: 200,
retry_after: None,
byte_stream: Box::pin(futures::stream::pending()),
error_body: String::new(),
})
}
}
fn glm_client(chunks: Vec<String>) -> OpenAiClient {
OpenAiClient::new("k".to_string(), "glm-test".to_string())
.with_http_client(std::sync::Arc::new(MockSseHttp { chunks }))
}
async fn drain_to_done(client: &OpenAiClient) -> crate::llm::LlmResponse {
use crate::llm::{LlmClient, StreamEvent};
let mut rx = client
.complete_streaming(
&[Message::user("go")],
None,
&[],
tokio_util::sync::CancellationToken::new(),
)
.await
.expect("stream opened");
let mut done = None;
while let Some(ev) = rx.recv().await {
if let StreamEvent::Done(resp) = ev {
done = Some(resp);
}
}
done.expect("a Done event")
}
#[tokio::test]
async fn streaming_parser_closes_when_caller_cancels() {
use crate::llm::LlmClient;
let client = OpenAiClient::new("k".to_string(), "model".to_string())
.with_http_client(std::sync::Arc::new(PendingSseHttp));
let cancellation = tokio_util::sync::CancellationToken::new();
let mut rx = client
.complete_streaming(&[Message::user("go")], None, &[], cancellation.clone())
.await
.expect("stream opened");
cancellation.cancel();
let next = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
.await
.expect("provider parser must stop after cancellation");
assert!(next.is_none());
}
#[tokio::test]
async fn streaming_reasoning_does_not_leak_into_content_and_keeps_tool_call() {
let chunks = vec![
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"Let me plan the workers\"}}]}\n\n"
.to_string(),
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"parallel_task\",\"arguments\":\"{}\"}}]}}]}\n\n"
.to_string(),
"data: [DONE]\n\n".to_string(),
];
let resp = drain_to_done(&glm_client(chunks)).await;
assert_eq!(resp.message.text(), "", "reasoning leaked into content");
assert_eq!(
resp.message.reasoning_content.as_deref(),
Some("Let me plan the workers")
);
let calls = resp.message.tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "parallel_task");
}
#[tokio::test]
async fn streaming_reasoning_only_turn_yields_empty_text() {
let chunks = vec![
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"still thinking, no answer yet\"}}]}\n\n"
.to_string(),
"data: [DONE]\n\n".to_string(),
];
let resp = drain_to_done(&glm_client(chunks)).await;
assert_eq!(resp.message.text(), "");
assert_eq!(
resp.message.reasoning_content.as_deref(),
Some("still thinking, no answer yet")
);
assert!(resp.message.tool_calls().is_empty());
}
#[tokio::test]
async fn streaming_collects_token_logprobs() {
let chunks = vec![
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"logprobs\":{\"content\":[{\"token\":\"hello\",\"logprob\":-0.2,\"bytes\":[104,101,108,108,111],\"top_logprobs\":[{\"token\":\"hi\",\"logprob\":-1.2,\"bytes\":[104,105]}]}]}}]}\n\n"
.to_string(),
"data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n"
.to_string(),
"data: [DONE]\n\n".to_string(),
];
let resp = drain_to_done(&glm_client(chunks).with_logprobs(true)).await;
assert_eq!(resp.text(), "hello");
assert_eq!(resp.token_logprobs.len(), 1);
assert_eq!(resp.token_logprobs[0].token, "hello");
assert_eq!(resp.token_logprobs[0].logprob, -0.2);
assert_eq!(
resp.token_logprobs[0].bytes.as_deref(),
Some(&[104, 101, 108, 108, 111][..])
);
assert_eq!(resp.token_logprobs[0].top_logprobs[0].token, "hi");
assert_eq!(resp.token_logprobs[0].top_logprobs[0].logprob, -1.2);
}
#[tokio::test]
async fn streaming_accepts_sse_data_without_space_after_colon() {
let chunks = vec![
"data:{\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":null}\n\n"
.to_string(),
"data:{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n"
.to_string(),
"data:[DONE]\n\n".to_string(),
];
let resp = drain_to_done(&glm_client(chunks)).await;
assert_eq!(resp.text(), "hello");
assert_eq!(resp.usage.prompt_tokens, 1);
assert_eq!(resp.usage.completion_tokens, 1);
assert_eq!(resp.usage.total_tokens, 2);
assert_eq!(resp.stop_reason.as_deref(), Some("stop"));
}
#[test]
fn test_apply_directive_forced_function_tool_choice() {
let mut req = serde_json::json!({ "model": "m" });
OpenAiClient::apply_directive(
&mut req,
&structured::StructuredDirective {
force_tool: Some("emit_person".to_string()),
response_format: None,
},
);
assert_eq!(req["tool_choice"]["type"], "function");
assert_eq!(req["tool_choice"]["function"]["name"], "emit_person");
assert!(req.get("response_format").is_none());
}
#[test]
fn test_apply_directive_json_schema_strict() {
let mut req = serde_json::json!({});
OpenAiClient::apply_directive(
&mut req,
&structured::StructuredDirective {
force_tool: None,
response_format: Some(structured::ResponseFormat::JsonSchema {
name: "person".to_string(),
schema: serde_json::json!({ "type": "object" }),
}),
},
);
assert_eq!(req["response_format"]["type"], "json_schema");
assert_eq!(req["response_format"]["json_schema"]["name"], "person");
assert_eq!(req["response_format"]["json_schema"]["strict"], true);
assert!(req.get("tool_choice").is_none());
}
#[test]
fn test_apply_directive_json_object() {
let mut req = serde_json::json!({});
OpenAiClient::apply_directive(
&mut req,
&structured::StructuredDirective {
force_tool: None,
response_format: Some(structured::ResponseFormat::JsonObject),
},
);
assert_eq!(req["response_format"]["type"], "json_object");
}
#[test]
fn test_build_chat_request_applies_directive_and_system() {
let req = make_client().build_chat_request(
&[Message::user("hi")],
Some("sys"),
&[ToolDefinition {
name: "emit_x".to_string(),
description: "emit".to_string(),
parameters: serde_json::json!({ "type": "object" }),
}],
Some(&structured::StructuredDirective {
force_tool: Some("emit_x".to_string()),
response_format: None,
}),
);
assert_eq!(req["messages"][0]["role"], "system");
assert_eq!(req["tool_choice"]["function"]["name"], "emit_x");
assert_eq!(req["tools"][0]["function"]["name"], "emit_x");
}
#[test]
fn test_build_chat_request_without_directive_is_plain() {
let req = make_client().build_chat_request(&[Message::user("hi")], None, &[], None);
assert!(req.get("tool_choice").is_none());
assert!(req.get("response_format").is_none());
assert!(req.get("logprobs").is_none());
assert!(req.get("top_logprobs").is_none());
}
#[test]
fn test_build_chat_request_includes_logprob_options_when_enabled() {
let req = make_client().with_top_logprobs(1).build_chat_request(
&[Message::user("hi")],
None,
&[],
None,
);
assert_eq!(req["logprobs"], true);
assert_eq!(req["top_logprobs"], 1);
}
#[test]
fn test_parse_openai_token_logprobs() {
let parsed = openai_logprobs_to_token_logprobs(&OpenAiChoiceLogprobs {
content: Some(vec![OpenAiTokenLogprob {
token: "hello".to_string(),
logprob: -0.25,
bytes: Some(vec![104, 101, 108, 108, 111]),
top_logprobs: vec![OpenAiTopLogprob {
token: "hi".to_string(),
logprob: -1.5,
bytes: Some(vec![104, 105]),
}],
}]),
});
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].token, "hello");
assert_eq!(parsed[0].logprob, -0.25);
assert_eq!(
parsed[0].bytes.as_deref(),
Some(&[104, 101, 108, 108, 111][..])
);
assert_eq!(parsed[0].top_logprobs[0].token, "hi");
assert_eq!(parsed[0].top_logprobs[0].logprob, -1.5);
}
#[test]
fn test_native_structured_support_is_json_schema() {
assert_eq!(
make_client().native_structured_support(),
structured::NativeStructuredSupport::JsonSchema
);
}
#[test]
fn test_native_structured_support_can_be_overridden() {
assert_eq!(
make_client()
.with_native_structured_support(structured::NativeStructuredSupport::None)
.native_structured_support(),
structured::NativeStructuredSupport::None
);
}