use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use yoagent::provider::{ModelConfig, OpenAiCompatProvider, StreamConfig, StreamProvider};
use yoagent::types::*;
fn chunk(json: &str) -> String {
format!("data: {json}\n\n")
}
fn stream_config(base_url: &str) -> StreamConfig {
let mut mc = ModelConfig::minimax("MiniMax-M2.7", "MiniMax M2.7");
mc.base_url = base_url.to_string();
let mut config = StreamConfig::new("MiniMax-M2.7", "test-key");
config.system_prompt = "test".into();
config.messages = vec![Message::user("hi")];
config.max_tokens = Some(256);
config.model_config = Some(mc);
config
}
async fn run_stream(config: StreamConfig) -> Result<Message, yoagent::provider::ProviderError> {
let (tx, _rx) = mpsc::unbounded_channel();
OpenAiCompatProvider
.stream(config, tx, CancellationToken::new())
.await
}
#[tokio::test]
async fn test_stream_ended_after_finish_reason_is_clean_eof() {
let server = MockServer::start().await;
let body = [
chunk(r#"{"choices":[{"delta":{"content":"Hello"},"index":0}]}"#),
chunk(r#"{"choices":[{"delta":{"content":" world"},"index":0}]}"#),
chunk(r#"{"choices":[{"delta":{},"finish_reason":"stop","index":0}]}"#),
]
.concat();
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(body, "text/event-stream"),
)
.expect(1)
.mount(&server)
.await;
let msg = run_stream(stream_config(&server.uri()))
.await
.expect("DONE-less close after finish_reason must not be an error");
let Message::Assistant {
content,
stop_reason,
..
} = &msg
else {
panic!("expected assistant message");
};
assert_eq!(*stop_reason, StopReason::Stop);
assert!(
matches!(&content[0], Content::Text { text } if text == "Hello world"),
"accumulated content must survive the DONE-less close: {content:?}"
);
}
#[tokio::test]
async fn test_stream_ended_without_finish_reason_is_error() {
let server = MockServer::start().await;
let body = chunk(r#"{"choices":[{"delta":{"content":"Hel"},"index":0}]}"#);
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(body, "text/event-stream"),
)
.mount(&server)
.await;
let result = run_stream(stream_config(&server.uri())).await;
assert!(
result.is_err(),
"truncation before finish_reason must stay an error, got {result:?}"
);
}
#[tokio::test]
async fn test_usage_chunk_after_finish_reason_survives_doneless_close() {
let server = MockServer::start().await;
let body = [
chunk(r#"{"choices":[{"delta":{"content":"Hi"},"index":0}]}"#),
chunk(r#"{"choices":[{"delta":{},"finish_reason":"stop","index":0}]}"#),
chunk(r#"{"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}"#),
]
.concat();
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(body, "text/event-stream"),
)
.mount(&server)
.await;
let msg = run_stream(stream_config(&server.uri()))
.await
.expect("clean");
let Message::Assistant { usage, .. } = &msg else {
panic!("expected assistant");
};
assert_eq!(
usage.input, 7,
"usage chunk after finish_reason must be captured"
);
assert_eq!(usage.output, 3);
}