mattrguerra/cortexmainPrivate on GitHub, published here in full

turn.rs

Language Rust7.6 KB184 lines

crates/cortex-core/tests/turn.rs

  1. //! Whole turns, database included, against the stand-in server.
  2. use std::sync::Mutex;
  3. use cortex_core::api::StreamEvent;
  4. use cortex_core::chat::{build_request, run_turn};
  5. use cortex_core::pricing::parse_priced_models;
  6. use cortex_core::provider::Client;
  7. use cortex_core::storage::{Db, NewAttachment, DEEPINFRA_ID};
  8. use cortex_core::types::UsageSource;
  9. use tokio_util::sync::CancellationToken;
  10. struct Setup {
  11. db: Mutex<Db>,
  12. client: Client,
  13. _s: standin::Standin,
  14. }
  15. async fn setup() -> Setup {
  16. let s = standin::spawn().await;
  17. let db = Db::open_in_memory().unwrap();
  18. db.replace_models(DEEPINFRA_ID, &parse_priced_models(DEEPINFRA_ID, &standin::models()), "live", 0).unwrap();
  19. let client = Client::new(&s.base_url(), Some("good-key".into()));
  20. Setup { db: Mutex::new(db), client, _s: s }
  21. }
  22. async fn turn(
  23. st: &Setup,
  24. model: &str,
  25. text: &str,
  26. cancel: &CancellationToken,
  27. ) -> (cortex_core::storage::Message, Vec<StreamEvent>) {
  28. let (req, assistant, prices) = {
  29. let db = st.db.lock().unwrap();
  30. let conv = db.create_conversation(DEEPINFRA_ID, model, "Answer briefly.", None, "main").unwrap();
  31. db.insert_user_message(&conv.id, text, &[]).unwrap();
  32. let req = build_request(&db, &conv.id).unwrap();
  33. let a = db.insert_assistant_placeholder(&conv.id, DEEPINFRA_ID, model).unwrap();
  34. let prices = db.model(DEEPINFRA_ID, model).unwrap().map(|m| m.prices);
  35. (req, a, prices)
  36. };
  37. let mut events = Vec::new();
  38. let m = run_turn(&st.db, &st.client, &req, &assistant, prices, cancel, |e| events.push(e)).await.unwrap();
  39. (m, events)
  40. }
  41. #[tokio::test]
  42. async fn a_priced_reply_is_saved_with_its_cost() {
  43. let st = setup().await;
  44. let (m, events) = turn(&st, "standin/echo", "tidy my backup script", &CancellationToken::new()).await;
  45. assert_eq!(m.status, "complete");
  46. assert_eq!(m.content, "You said: tidy my backup script");
  47. assert_eq!(m.usage_source, UsageSource::Reported);
  48. // system (2 words) + user (4) + 4 per message x2 = 14 in; 6 words out.
  49. assert_eq!((m.usage.prompt_tokens, m.usage.completion_tokens), (14, 6));
  50. // 14 x $0.50/M + 6 x $1.50/M = 7,000 + 9,000 nano
  51. assert_eq!(m.cost_nanousd, 16_000);
  52. assert_eq!(m.prices.unwrap().input_per_m, 0.5);
  53. let text: String = events
  54. .iter()
  55. .filter_map(|e| if let StreamEvent::Delta { text } = e { Some(text.as_str()) } else { None })
  56. .collect();
  57. assert_eq!(text, m.content);
  58. assert!(matches!(events.last(), Some(StreamEvent::Done { .. })));
  59. assert!(events.iter().any(|e| matches!(e, StreamEvent::Usage { .. })));
  60. }
  61. #[tokio::test]
  62. async fn no_usage_means_an_estimate_marked_as_such() {
  63. let st = setup().await;
  64. let (m, _) = turn(&st, "standin/no-usage", "hello", &CancellationToken::new()).await;
  65. assert_eq!(m.usage_source, UsageSource::Estimated);
  66. assert!(m.usage.prompt_tokens > 0 && m.usage.completion_tokens > 0);
  67. assert!(m.cost_nanousd > 0);
  68. }
  69. #[tokio::test]
  70. async fn cached_prompt_tokens_bill_at_the_cache_price() {
  71. let st = setup().await;
  72. let (m, _) =
  73. turn(&st, "standin/cached", "one two three four five six seven eight nine ten", &CancellationToken::new())
  74. .await;
  75. // 2 + 10 words + 4 per message = 20 in, all of it cached (the stand-in caches up to 40).
  76. let u = m.usage;
  77. assert_eq!((u.prompt_tokens, u.cached_tokens, u.completion_tokens), (20, 20, 12));
  78. let expect = (u.prompt_tokens - u.cached_tokens) * 500 + u.cached_tokens * 100 + u.completion_tokens * 1500;
  79. assert_eq!(m.cost_nanousd, expect);
  80. }
  81. #[tokio::test]
  82. async fn cancelling_saves_the_partial_reply() {
  83. let st = setup().await;
  84. let cancel = CancellationToken::new();
  85. let c2 = cancel.clone();
  86. tokio::spawn(async move {
  87. tokio::time::sleep(std::time::Duration::from_millis(400)).await;
  88. c2.cancel();
  89. });
  90. let (m, events) = turn(&st, "standin/slow", "go", &cancel).await;
  91. assert_eq!(m.status, "cancelled");
  92. assert!(m.content.starts_with("word1"), "{}", m.content);
  93. assert!(!m.content.contains("word200"));
  94. assert_eq!(m.usage_source, UsageSource::Estimated);
  95. let saved = st.db.lock().unwrap().get_message(&m.id).unwrap();
  96. assert_eq!(saved.status, "cancelled");
  97. assert!(matches!(events.last(), Some(StreamEvent::Done { .. })));
  98. }
  99. #[tokio::test]
  100. async fn failures_are_saved_and_reported() {
  101. let st = setup().await;
  102. let (m, events) = turn(&st, "standin/402", "x", &CancellationToken::new()).await;
  103. assert_eq!((m.status.as_str(), m.error_kind.as_deref()), ("error", Some("insufficient_balance")));
  104. assert_eq!((m.cost_nanousd, m.usage_source), (0, UsageSource::None));
  105. assert!(matches!(events.last(), Some(StreamEvent::Error { kind, .. }) if kind == "insufficient_balance"));
  106. let (m, _) = turn(&st, "standin/midstream-error", "a b c d", &CancellationToken::new()).await;
  107. assert_eq!(m.status, "error");
  108. assert_eq!(m.content, "You said: a");
  109. assert_eq!(m.usage_source, UsageSource::Estimated);
  110. }
  111. #[tokio::test]
  112. async fn history_attachments_and_failed_replies() {
  113. let st = setup().await;
  114. let dir = tempfile::tempdir().unwrap();
  115. let img_path = dir.path().join("x.png");
  116. std::fs::write(&img_path, b"PNGDATA").unwrap();
  117. let req = {
  118. let db = st.db.lock().unwrap();
  119. let conv = db.create_conversation(DEEPINFRA_ID, "standin/vision", "", None, "main").unwrap();
  120. let img = db
  121. .insert_attachment(&NewAttachment {
  122. kind: "image",
  123. filename: "chart.png",
  124. mime: "image/png",
  125. byte_size: 7,
  126. sha256: "h",
  127. width: Some(1),
  128. height: Some(1),
  129. path: Some(img_path.to_str().unwrap()),
  130. text_content: None,
  131. })
  132. .unwrap();
  133. let txt = db
  134. .insert_attachment(&NewAttachment {
  135. kind: "text",
  136. filename: "notes.md",
  137. mime: "text/markdown",
  138. byte_size: 5,
  139. sha256: "t",
  140. width: None,
  141. height: None,
  142. path: None,
  143. text_content: Some("Q3 was flat."),
  144. })
  145. .unwrap();
  146. db.insert_user_message(&conv.id, "compare these", &[img.id, txt.id]).unwrap();
  147. let bad = db.insert_assistant_placeholder(&conv.id, DEEPINFRA_ID, "standin/vision").unwrap();
  148. db.finalize_assistant(
  149. &bad.id,
  150. &cortex_core::storage::Finalize { content: "half".into(), status: "error", ..Default::default() },
  151. )
  152. .unwrap();
  153. db.insert_user_message(&conv.id, "try again", &[]).unwrap();
  154. build_request(&db, &conv.id).unwrap()
  155. };
  156. let v = serde_json::to_value(&req).unwrap();
  157. let msgs = v["messages"].as_array().unwrap();
  158. assert_eq!(msgs.len(), 2, "no system prompt, and the failed reply is left out: {v}");
  159. let parts = msgs[0]["content"].as_array().unwrap();
  160. assert_eq!(parts[0]["text"], "compare these\n\nFile: notes.md\n```\nQ3 was flat.\n```");
  161. assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,UE5HREFUQQ==");
  162. assert_eq!(msgs[1]["content"], "try again");
  163. // And the vision stand-in counts the one image.
  164. let a = {
  165. let db = st.db.lock().unwrap();
  166. let conv_id = db.list_conversations(false, 1).unwrap()[0].id.clone();
  167. db.insert_assistant_placeholder(&conv_id, DEEPINFRA_ID, "standin/vision").unwrap()
  168. };
  169. let m = run_turn(&st.db, &st.client, &req, &a, None, &CancellationToken::new(), |_| {}).await.unwrap();
  170. assert_eq!(m.content, "I see 1 image.");
  171. assert_eq!(m.cost_nanousd, 0, "unknown prices cost nothing rather than a guess");
  172. }