use std::time::Duration;
use aimux_core::AbortSignal;
use aimux_core::error::AiMuxError;
use aimux_core::generate::{GenerateTextOptions, stream_text};
use aimux_core::language_model::LanguageModel;
use aimux_core::options::{CallOptions, TimeoutConfiguration};
use aimux_core::result::{GenerateResult, StreamResult};
use aimux_core::stream_part::StreamPart;
use async_trait::async_trait;
use futures::StreamExt;
struct ProbeModel {
second_part_is_output: bool,
}
fn delta(text: &str) -> StreamPart {
StreamPart::TextDelta {
id: "1".into(),
delta: text.into(),
provider_metadata: None,
}
}
#[async_trait]
impl LanguageModel for ProbeModel {
fn provider(&self) -> &str {
"mock-probe"
}
fn model_id(&self) -> &str {
"probe-1"
}
async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
unimplemented!("streaming-only mock")
}
async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
let second_is_output = self.second_part_is_output;
Ok(StreamResult {
stream: Box::pin(async_stream::stream! {
yield Ok(delta("first"));
tokio::time::sleep(Duration::from_millis(600)).await;
if second_is_output {
yield Ok(delta("second"));
} else {
yield Ok(StreamPart::TextStart {
id: "1".into(),
provider_metadata: None,
});
}
futures::future::pending::<()>().await;
}),
request_body: None,
response_headers: None,
})
}
}
fn chunk_options() -> GenerateTextOptions {
GenerateTextOptions {
max_retries: Some(0),
timeout: Some(TimeoutConfiguration {
chunk_ms: Some(1_000),
..Default::default()
}),
..Default::default()
}
}
async fn time_to_chunk_timeout(second_part_is_output: bool) -> Duration {
let model = ProbeModel {
second_part_is_output,
};
let mut result = stream_text(&model, "hello", chunk_options()).await.unwrap();
let start = tokio::time::Instant::now();
loop {
match result.stream.next().await.expect("stream must not end") {
Ok(_) => {}
Err(AiMuxError::Timeout(message)) => {
assert_eq!(message, "Chunk timeout of 1000ms exceeded");
return start.elapsed();
}
Err(other) => panic!("unexpected error: {other}"),
}
}
}
#[tokio::test(start_paused = true)]
async fn non_semantic_part_does_not_reset_the_chunk_timer() {
assert_eq!(
time_to_chunk_timeout(false).await,
Duration::from_millis(1_000)
);
}
#[tokio::test(start_paused = true)]
async fn semantic_output_resets_the_chunk_timer() {
assert_eq!(
time_to_chunk_timeout(true).await,
Duration::from_millis(1_600)
);
}
#[tokio::test(start_paused = true)]
async fn consumer_delay_does_not_count_against_the_chunk_timer() {
let model = ProbeModel {
second_part_is_output: true,
};
let mut result = stream_text(&model, "hello", chunk_options()).await.unwrap();
assert!(result.stream.next().await.expect("first part").is_ok());
tokio::time::sleep(Duration::from_millis(2_000)).await;
match result.stream.next().await.expect("second part") {
Ok(StreamPart::TextDelta { delta, .. }) => assert_eq!(delta, "second"),
other => panic!("expected the buffered second delta, got {other:?}"),
}
match result.stream.next().await.expect("timeout") {
Err(AiMuxError::Timeout(message)) => {
assert_eq!(message, "Chunk timeout of 1000ms exceeded");
}
other => panic!("expected chunk timeout, got {other:?}"),
}
assert!(result.stream.next().await.is_none());
}
#[tokio::test(start_paused = true)]
async fn dropped_operation_leaves_no_armed_timer_state() {
let caller = AbortSignal::new();
let model = ProbeModel {
second_part_is_output: false,
};
let options = GenerateTextOptions {
max_retries: Some(0),
abort_signal: Some(caller.clone()),
timeout: Some(TimeoutConfiguration {
total_ms: Some(5_000),
chunk_ms: Some(1_000),
..Default::default()
}),
..Default::default()
};
let mut result = stream_text(&model, "hello", options).await.unwrap();
let first = result.stream.next().await.expect("first part");
assert!(first.is_ok());
drop(result);
tokio::time::sleep(Duration::from_secs(60)).await;
assert!(!caller.is_aborted());
}