turn.rs
Language Rust7.6 KB184 lines
//! Whole turns, database included, against the stand-in server.use std::sync::Mutex;use cortex_core::api::StreamEvent;use cortex_core::chat::{build_request, run_turn};use cortex_core::pricing::parse_priced_models;use cortex_core::provider::Client;use cortex_core::storage::{Db, NewAttachment, DEEPINFRA_ID};use cortex_core::types::UsageSource;use tokio_util::sync::CancellationToken;struct Setup {db: Mutex<Db>,client: Client,_s: standin::Standin,}async fn setup() -> Setup {let s = standin::spawn().await;let db = Db::open_in_memory().unwrap();db.replace_models(DEEPINFRA_ID, &parse_priced_models(DEEPINFRA_ID, &standin::models()), "live", 0).unwrap();let client = Client::new(&s.base_url(), Some("good-key".into()));Setup { db: Mutex::new(db), client, _s: s }}async fn turn(st: &Setup,model: &str,text: &str,cancel: &CancellationToken,) -> (cortex_core::storage::Message, Vec<StreamEvent>) {let (req, assistant, prices) = {let db = st.db.lock().unwrap();let conv = db.create_conversation(DEEPINFRA_ID, model, "Answer briefly.", None, "main").unwrap();db.insert_user_message(&conv.id, text, &[]).unwrap();let req = build_request(&db, &conv.id).unwrap();let a = db.insert_assistant_placeholder(&conv.id, DEEPINFRA_ID, model).unwrap();let prices = db.model(DEEPINFRA_ID, model).unwrap().map(|m| m.prices);(req, a, prices)};let mut events = Vec::new();let m = run_turn(&st.db, &st.client, &req, &assistant, prices, cancel, |e| events.push(e)).await.unwrap();(m, events)}#[tokio::test]async fn a_priced_reply_is_saved_with_its_cost() {let st = setup().await;let (m, events) = turn(&st, "standin/echo", "tidy my backup script", &CancellationToken::new()).await;assert_eq!(m.status, "complete");assert_eq!(m.content, "You said: tidy my backup script");assert_eq!(m.usage_source, UsageSource::Reported);// system (2 words) + user (4) + 4 per message x2 = 14 in; 6 words out.assert_eq!((m.usage.prompt_tokens, m.usage.completion_tokens), (14, 6));// 14 x $0.50/M + 6 x $1.50/M = 7,000 + 9,000 nanoassert_eq!(m.cost_nanousd, 16_000);assert_eq!(m.prices.unwrap().input_per_m, 0.5);let text: String = events.iter().filter_map(|e| if let StreamEvent::Delta { text } = e { Some(text.as_str()) } else { None }).collect();assert_eq!(text, m.content);assert!(matches!(events.last(), Some(StreamEvent::Done { .. })));assert!(events.iter().any(|e| matches!(e, StreamEvent::Usage { .. })));}#[tokio::test]async fn no_usage_means_an_estimate_marked_as_such() {let st = setup().await;let (m, _) = turn(&st, "standin/no-usage", "hello", &CancellationToken::new()).await;assert_eq!(m.usage_source, UsageSource::Estimated);assert!(m.usage.prompt_tokens > 0 && m.usage.completion_tokens > 0);assert!(m.cost_nanousd > 0);}#[tokio::test]async fn cached_prompt_tokens_bill_at_the_cache_price() {let st = setup().await;let (m, _) =turn(&st, "standin/cached", "one two three four five six seven eight nine ten", &CancellationToken::new()).await;// 2 + 10 words + 4 per message = 20 in, all of it cached (the stand-in caches up to 40).let u = m.usage;assert_eq!((u.prompt_tokens, u.cached_tokens, u.completion_tokens), (20, 20, 12));let expect = (u.prompt_tokens - u.cached_tokens) * 500 + u.cached_tokens * 100 + u.completion_tokens * 1500;assert_eq!(m.cost_nanousd, expect);}#[tokio::test]async fn cancelling_saves_the_partial_reply() {let st = setup().await;let cancel = CancellationToken::new();let c2 = cancel.clone();tokio::spawn(async move {tokio::time::sleep(std::time::Duration::from_millis(400)).await;c2.cancel();});let (m, events) = turn(&st, "standin/slow", "go", &cancel).await;assert_eq!(m.status, "cancelled");assert!(m.content.starts_with("word1"), "{}", m.content);assert!(!m.content.contains("word200"));assert_eq!(m.usage_source, UsageSource::Estimated);let saved = st.db.lock().unwrap().get_message(&m.id).unwrap();assert_eq!(saved.status, "cancelled");assert!(matches!(events.last(), Some(StreamEvent::Done { .. })));}#[tokio::test]async fn failures_are_saved_and_reported() {let st = setup().await;let (m, events) = turn(&st, "standin/402", "x", &CancellationToken::new()).await;assert_eq!((m.status.as_str(), m.error_kind.as_deref()), ("error", Some("insufficient_balance")));assert_eq!((m.cost_nanousd, m.usage_source), (0, UsageSource::None));assert!(matches!(events.last(), Some(StreamEvent::Error { kind, .. }) if kind == "insufficient_balance"));let (m, _) = turn(&st, "standin/midstream-error", "a b c d", &CancellationToken::new()).await;assert_eq!(m.status, "error");assert_eq!(m.content, "You said: a");assert_eq!(m.usage_source, UsageSource::Estimated);}#[tokio::test]async fn history_attachments_and_failed_replies() {let st = setup().await;let dir = tempfile::tempdir().unwrap();let img_path = dir.path().join("x.png");std::fs::write(&img_path, b"PNGDATA").unwrap();let req = {let db = st.db.lock().unwrap();let conv = db.create_conversation(DEEPINFRA_ID, "standin/vision", "", None, "main").unwrap();let img = db.insert_attachment(&NewAttachment {kind: "image",filename: "chart.png",mime: "image/png",byte_size: 7,sha256: "h",width: Some(1),height: Some(1),path: Some(img_path.to_str().unwrap()),text_content: None,}).unwrap();let txt = db.insert_attachment(&NewAttachment {kind: "text",filename: "notes.md",mime: "text/markdown",byte_size: 5,sha256: "t",width: None,height: None,path: None,text_content: Some("Q3 was flat."),}).unwrap();db.insert_user_message(&conv.id, "compare these", &[img.id, txt.id]).unwrap();let bad = db.insert_assistant_placeholder(&conv.id, DEEPINFRA_ID, "standin/vision").unwrap();db.finalize_assistant(&bad.id,&cortex_core::storage::Finalize { content: "half".into(), status: "error", ..Default::default() },).unwrap();db.insert_user_message(&conv.id, "try again", &[]).unwrap();build_request(&db, &conv.id).unwrap()};let v = serde_json::to_value(&req).unwrap();let msgs = v["messages"].as_array().unwrap();assert_eq!(msgs.len(), 2, "no system prompt, and the failed reply is left out: {v}");let parts = msgs[0]["content"].as_array().unwrap();assert_eq!(parts[0]["text"], "compare these\n\nFile: notes.md\n```\nQ3 was flat.\n```");assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,UE5HREFUQQ==");assert_eq!(msgs[1]["content"], "try again");// And the vision stand-in counts the one image.let a = {let db = st.db.lock().unwrap();let conv_id = db.list_conversations(false, 1).unwrap()[0].id.clone();db.insert_assistant_placeholder(&conv_id, DEEPINFRA_ID, "standin/vision").unwrap()};let m = run_turn(&st.db, &st.client, &req, &a, None, &CancellationToken::new(), |_| {}).await.unwrap();assert_eq!(m.content, "I see 1 image.");assert_eq!(m.cost_nanousd, 0, "unknown prices cost nothing rather than a guess");}