provider.rs
Language Rust6.8 KB181 lines
//! The provider client against the stand-in server: every path a real stream//! can take, including cancelling mid-reply.use std::time::Duration;use cortex_core::provider::{ChatRequest, Client, OutMessage, ProviderError, StreamItem, StreamOptions};use cortex_core::types::Usage;use tokio_util::sync::CancellationToken;fn req(model: &str, text: &str) -> ChatRequest {ChatRequest {model: model.into(),messages: vec![OutMessage::text("system", "be brief"), OutMessage::text("user", text)],stream: true,stream_options: Some(StreamOptions { include_usage: true }),temperature: None,max_tokens: None,}}async fn run(client: &Client, r: &ChatRequest) -> (cortex_core::provider::StreamOutcome, Vec<StreamItem>) {let mut items = Vec::new();let out = client.stream_chat(r, &CancellationToken::new(), |i| items.push(i)).await;(out, items)}#[tokio::test]async fn streams_text_and_final_usage() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), Some("good-key".into()));let (out, items) = run(&c, &req("standin/echo", "hello there friend")).await;assert_eq!(out.error, None);assert_eq!(out.content, "You said: hello there friend");assert_eq!(out.finish_reason.as_deref(), Some("stop"));// 2 + 3 words, plus 4 per messageassert_eq!(out.usage, Some(Usage { prompt_tokens: 13, completion_tokens: 5, cached_tokens: 0 }));let deltas = items.iter().filter(|i| matches!(i, StreamItem::Content(_))).count();assert_eq!(deltas, 5);assert!(out.ttft_ms.is_some());let rec = &s.requests()[0];assert_eq!(rec.authorization.as_deref(), Some("Bearer good-key"));assert_eq!(rec.body["stream_options"]["include_usage"], true);assert_eq!(rec.body["stream"], true);}#[tokio::test]async fn no_key_sends_no_auth_header() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), Some(String::new()));run(&c, &req("standin/echo", "hi")).await;assert_eq!(s.requests()[0].authorization, None);}#[tokio::test]async fn usage_variants() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let (out, _) = run(&c, &req("standin/inline-usage", "a b")).await;assert_eq!(out.usage.unwrap().completion_tokens, 4);let (out, _) = run(&c, &req("standin/no-usage", "a b")).await;assert_eq!(out.usage, None);assert_eq!(out.content, "You said: a b");let (out, _) = run(&c, &req("standin/cached", "one two three")).await;assert_eq!(out.usage.unwrap().cached_tokens, 13);assert_eq!(out.provider_cost_usd, Some(0.0000123));}#[tokio::test]async fn reasoning_is_separate_from_content() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let (out, items) = run(&c, &req("standin/reasoning", "why")).await;assert_eq!(out.reasoning, "Checking the question.");assert_eq!(out.content, "You said: why");assert!(matches!(items[0], StreamItem::Reasoning(_)));}#[tokio::test]async fn images_go_as_content_parts() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let mut r = req("standin/vision", "");r.messages[1] = OutMessage::with_images("user","what is in these",vec!["data:image/png;base64,AAAA".into(), "data:image/jpeg;base64,BBBB".into()],);let (out, _) = run(&c, &r).await;assert_eq!(out.content, "I see 2 images.");let body = &s.requests()[0].body;assert_eq!(body["messages"][1]["content"][2]["type"], "image_url");}#[tokio::test]async fn http_errors_map_to_plain_errors() {let s = standin::spawn().await;let bad = Client::new(&s.base_url(), Some("bad-key".into()));assert_eq!(run(&bad, &req("standin/echo", "x")).await.0.error, Some(ProviderError::InvalidKey));// Saving a key checks it this way before storing it.assert_eq!(bad.list_models().await.unwrap_err(), ProviderError::InvalidKey);let c = Client::new(&s.base_url(), None);assert_eq!(run(&c, &req("standin/402", "x")).await.0.error, Some(ProviderError::InsufficientBalance));assert_eq!(run(&c, &req("standin/429", "x")).await.0.error,Some(ProviderError::RateLimited { retry_after: Some(7) }));assert_eq!(run(&c, &req("standin/500", "x")).await.0.error,Some(ProviderError::Upstream { status: 500, message: "upstream exploded".into() }));}#[tokio::test]async fn midstream_error_keeps_the_partial_reply() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let (out, _) = run(&c, &req("standin/midstream-error", "one two three four")).await;assert_eq!(out.content, "You said: one"); // three words, then the errorassert_eq!(out.error, Some(ProviderError::Stream("model overloaded".into())));assert_eq!(out.usage, None);}#[tokio::test]async fn unreachable_server() {// Bind then drop a listener to get a port with nothing on it.let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();let c = Client::new(&format!("http://127.0.0.1:{port}/v1"), None);let (out, _) = run(&c, &req("standin/echo", "x")).await;assert_eq!(out.error.unwrap().kind(), "unreachable");}#[tokio::test]async fn cancel_drops_the_connection_and_keeps_what_arrived() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let cancel = CancellationToken::new();let mut deltas = 0;let trigger = cancel.clone();let out = c.stream_chat(&req("standin/slow", "go"), &cancel, |i| {if matches!(i, StreamItem::Content(_)) {deltas += 1;if deltas == 3 {trigger.cancel();}}}).await;assert!(out.cancelled);assert_eq!(out.error, None);assert!(out.content.starts_with("word1 word2"), "{}", out.content);assert!(out.content.len() < 60);// The server notices the dropped connection on its next write.let mut seen = 0;for _ in 0..40 {seen = s.disconnects();if seen > 0 {break;}tokio::time::sleep(Duration::from_millis(50)).await;}assert_eq!(seen, 1, "server never saw the client go away");}#[tokio::test]async fn cancel_before_the_response_arrives() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let cancel = CancellationToken::new();cancel.cancel();let out = c.stream_chat(&req("standin/echo", "x"), &cancel, |_| {}).await;assert!(out.cancelled && out.content.is_empty());}#[tokio::test]async fn lists_models() {let s = standin::spawn().await;let c = Client::new(&s.base_url(), None);let v = c.list_models().await.unwrap();assert_eq!(v["data"][0]["id"], "standin/echo");}