File size: 67,210 Bytes
d2d4a42 19886eb ddda5be ab0f9c6 ddda5be ab0f9c6 d2d4a42 ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 19886eb d2d4a42 ab0f9c6 ddda5be d2d4a42 ab0f9c6 ddda5be ab0f9c6 ddda5be 19886eb ddda5be 19886eb ddda5be ab0f9c6 19886eb ab0f9c6 19886eb ab0f9c6 19886eb ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 19886eb ddda5be 19886eb ab0f9c6 19886eb ab0f9c6 19886eb ab0f9c6 ddda5be ab0f9c6 19886eb ab0f9c6 ddda5be d2d4a42 ddda5be d71bad3 ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 19886eb ddda5be 19886eb ddda5be 19886eb ab0f9c6 ddda5be ab0f9c6 ddda5be ab0f9c6 19886eb ab0f9c6 19886eb ab0f9c6 19886eb ab0f9c6 19886eb d2d4a42 19886eb a31d7f2 80e7e3a 19886eb 80e7e3a 19886eb a31d7f2 19886eb ddda5be 19886eb 80e7e3a ddda5be 80e7e3a ddda5be 80e7e3a ddda5be 80e7e3a ddda5be 80e7e3a 19886eb ddda5be 19886eb ddda5be 19886eb ddda5be 19886eb d2d4a42 19886eb ddda5be 19886eb ddda5be 19886eb ddda5be d2d4a42 ddda5be d2d4a42 ddda5be d2d4a42 ddda5be 19886eb d2d4a42 ddda5be 19886eb d2d4a42 19886eb d2d4a42 19886eb ddda5be d2d4a42 ddda5be d2d4a42 ddda5be d71bad3 ddda5be 19886eb 80e7e3a ddda5be 80e7e3a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 | //! dllm-server: the HTTP layer of span-02 (`POST /v1/systemone`, `GET /health`): request validation, prompt construction,
//! answer assembly (blend, choice / noul / score fields), and the GPU engine behind it (`Args` = the command line, `start_engine` loads the model,
//! the MTP draft and the vision tower and starts the scheduler).
pub mod assets;
pub mod wire;
use axum::{
body::Bytes,
extract::State,
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response as HttpResponse},
routing::{get, post},
Json, Router,
};
use dllm_core::policy::{self, Answer, Policy, ThinkMode};
use dllm_core::tokenizer::{img_soft_max, ostr, state_text, Tok};
use serde_json::{json, Value};
use std::sync::Arc;
use wire::{AnswerOut, ApiError, OrderedMap, ReasoningOut, Request, Response, Usage};
pub use dllm_core::serving::{Item, ItemResult};
pub trait Backend: Send + Sync {
/// All questions of ONE request share the state prefix (and images): the engine batches their branches in one forward.
fn run(&self, items: &[Item], images: &[Vec<u8>], pol: &Policy) -> anyhow::Result<Vec<ItemResult>>;
}
pub struct AppState {
pub tok: Tok,
pub backend: Box<dyn Backend>,
pub pol: Policy,
pub api_key: Option<String>,
pub max_len: usize,
pub max_body: usize,
pub served_name: String,
}
pub fn app(st: Arc<AppState>) -> Router {
Router::new().route("/health", get(health)).route("/v1/systemone", post(systemone)).fallback(not_found).with_state(st)
}
/// any other path: 404 in the error format, after reading the body (a proxy in front then sees a complete exchange instead of a reset)
async fn not_found(State(st): State<Arc<AppState>>, body: axum::body::Body) -> HttpResponse {
let _ = axum::body::to_bytes(body, st.max_body).await;
err(ApiError::new(404, "not found: the API is POST /v1/systemone and GET /health").kind("not_found"))
}
async fn health(State(st): State<Arc<AppState>>) -> Json<Value> {
Json(json!({"ok": true, "model": st.served_name}))
}
fn err(e: ApiError) -> HttpResponse {
(StatusCode::from_u16(e.code).unwrap(), Json(json!({"error": {"type": e.kind, "message": e.msg}}))).into_response()
}
pub fn decode_images(v: &Option<Value>) -> Result<Vec<Vec<u8>>, ApiError> {
let bad = || ApiError::new(422, "images must be a list of at most 4 data URIs");
let Some(v) = v else { return Ok(vec![]) };
if v.is_null() {
return Ok(vec![]);
}
let a = v.as_array().filter(|a| a.len() <= 4).ok_or_else(bad)?;
a.iter()
.map(|s| {
let s = s.as_str().ok_or_else(bad)?;
let b64 = s.split_once(',').map(|x| x.1).filter(|_| s.starts_with("data:")).unwrap_or(s);
base64_simd::STANDARD.decode_to_vec(b64.trim()).map_err(|_| ApiError::new(422, "cannot decode image as a base64 data URI"))
})
.collect()
}
/// thinking tokens reserved in the context check of one question with `rows` prompt rows (images expanded at the maximum): the hard cap, or with
/// `--think-ms` the cap the deterministic cost model gives it as a lone thinking question (the engine also clamps every thought to the room left
/// in the context, so a request whose real cap comes out larger still fits)
pub fn think_reserve(pol: &Policy, rows: usize, n_images: usize) -> usize {
if pol.think_ms == 0 {
return pol.cap;
}
let s = dllm_core::policy::ThinkShape { prefill_tokens: rows, images: n_images, image_bytes: 0, questions: 1, thinking: 1 };
pol.think_cost.cap(pol.think_ms as f64, &s, pol.cap)
}
/// per-request policy: the server policy with `reasoning` applied
pub fn request_policy(base: &Policy, req: &Request) -> Result<Policy, ApiError> {
let r = req.reasoning.as_ref();
let (mode, budget, trace) = r.map_or((None, None, None), |r| (r.mode.as_deref(), r.budget_ms, r.trace));
let m = match mode {
None | Some("auto") => ThinkMode::Auto,
Some("on") => ThinkMode::On,
Some("off") => ThinkMode::Off,
Some(o) => return Err(ApiError::new(422, format!("reasoning.mode must be \"auto\", \"on\" or \"off\", got {o:?}"))),
};
// wall-clock budget: the request's `budget_ms` if sent; mode "on" without one thinks to completion (up to the cap); otherwise the server's.
// `--think-ms` (the deterministic token cap) applies in every case; `budget_ms` only adds or removes the wall-clock deadline.
let budget_ms = match budget {
Some(b) if !(b.is_finite() && (0.0..=MAX_BUDGET_MS).contains(&b)) => return Err(ApiError::new(422, format!("reasoning.budget_ms must be a number of milliseconds in [0, {MAX_BUDGET_MS}], got {b}"))),
Some(b) => b.round() as u32,
None if m == ThinkMode::On => 0,
None => base.budget_ms,
};
Ok(Policy { return_trace: trace.unwrap_or(false), budget_ms, ..base.with_think(m) })
}
/// largest accepted per-request `budget_ms` (10 minutes)
pub const MAX_BUDGET_MS: f64 = 600_000.0;
/// TypeSafe score confidence: max(0, 1 - E|i - m| / MAD_unif), m = most likely level (first on ties), MAD_unif = mean |i - (n-1)/2|
pub fn score_confidence(p: &[f32]) -> f32 {
let n = p.len();
if n < 2 {
return 1.0;
}
let m = policy::argmax_first(p) as f64;
let mad: f64 = p.iter().enumerate().map(|(i, &q)| q as f64 * (i as f64 - m).abs()).sum();
let c = (n as f64 - 1.0) / 2.0;
let unif = (0..n).map(|i| (i as f64 - c).abs()).sum::<f64>() / n as f64;
(1.0 - mad / unif).max(0.0) as f32
}
/// noul option texts: A = true side, B = false side (our trained order). No criteria (absent, null or an empty object `{}`, which DI
/// rows send, e.g. calibration-v1 phishing signals) renders exactly as before ("True"/"False"); explicit criteria must be an object with
/// both "true" and "false" (a null side keeps its default).
fn noul_options(key: &str, c: Option<&Value>) -> Result<Vec<String>, ApiError> {
let Some(c) = c.filter(|c| !c.is_null() && c.as_object().map_or(true, |o| !o.is_empty())) else { return Ok(vec!["True".into(), "False".into()]) };
let bad = || ApiError::new(422, format!("question {key}: noul criteria must be an object with exactly the keys \"true\" and \"false\""));
let o = c.as_object().filter(|o| o.len() == 2).ok_or_else(bad)?;
let side = |k: &str, d: &str| o.get(k).map(|v| if v.is_null() { d.to_string() } else { ostr(v) }).ok_or_else(bad);
Ok(vec![side("true", "True")?, side("false", "False")?])
}
/// TypeSafe choice confidence: the returned choice's probability rescaled from uniform (0) to certain (1)
pub fn choice_confidence(p_max: f32, n: usize) -> f32 {
if n < 2 {
return 1.0;
}
let u = 1.0 / n as f64;
((p_max as f64 - u) / (1.0 - u)).clamp(0.0, 1.0) as f32
}
pub const SCORE_MAX_LEVELS: usize = 10;
pub fn build_items(st: &AppState, req: &Request, n_images: usize) -> Result<Vec<Item>, ApiError> {
build_questions(st, req, n_images).map(|x| x.0)
}
/// `build_prompts` over the request's own state and questions
pub fn build_questions(st: &AppState, req: &Request, n_images: usize) -> Result<(Vec<Item>, Vec<Option<Vec<Value>>>), ApiError> {
build_prompts(st, &req.state, &req.questions, n_images)
}
/// items + per-item score legend (Some(levels as sent) for `score` questions; they run exactly like choice with keys "0".."n-1")
pub fn build_prompts(st: &AppState, state: &Value, questions: &serde_json::Map<String, Value>, n_images: usize) -> Result<(Vec<Item>, Vec<Option<Vec<Value>>>), ApiError> {
if questions.is_empty() {
return Err(ApiError::new(422, "questions must be a non-empty object"));
}
let stxt = state_text(state);
let mut items = vec![];
let mut legends = vec![];
for (key, q) in questions {
let q = q.as_object().ok_or_else(|| ApiError::new(422, format!("question {key}: unsupported type")))?;
let ty = q.get("type").and_then(|x| x.as_str());
let mut legend = None;
if ty == Some("number") {
// free-form number (our extension): no criteria; the answer is generated after the end-of-thought token
let instr = q.get("instructions").map(ostr).unwrap_or_default();
let b = st.tok.build_number(&stxt, &instr, n_images).map_err(|e| ApiError::new(422, format!("bad request: {e}")))?;
let reserve = think_reserve(&st.pol, b.ids.len() + (img_soft_max() + 2) * n_images, n_images);
let need = b.ids.len() + (img_soft_max() + 2) * n_images + reserve + 3 + dllm_runtime::engine::ANSWER_MAX;
if need > st.max_len {
return Err(ApiError::context(format!("This model's maximum context length is {} tokens. However, the request needs about {} tokens (prompt + thinking budget {} + answer).", st.max_len, need, reserve)));
}
items.push(Item { key: key.clone(), number: true, ids: b.ids, ..Default::default() });
legends.push(None);
continue;
}
let (noul, keys, opts): (bool, Vec<String>, Vec<String>) = match ty {
Some("noul") => (true, vec!["true".into(), "false".into()], noul_options(key, q.get("criteria"))?),
Some("choice") => {
let c = q.get("criteria").and_then(|x| x.as_object()).filter(|c| !c.is_empty()).ok_or_else(|| ApiError::new(422, format!("question {key}: criteria must be a non-empty object")))?;
// a null description defaults to its option key (TypeSafe / surogate)
(false, c.keys().cloned().collect(), c.iter().map(|(k, v)| if v.is_null() { k.clone() } else { ostr(v) }).collect())
}
Some("score") => {
let c = q.get("criteria").and_then(|x| x.as_array()).ok_or_else(|| ApiError::new(422, format!("question {key}: criteria must be an array of level descriptions")))?;
if c.len() < 2 || c.len() > SCORE_MAX_LEVELS {
return Err(ApiError::new(422, format!("question {key}: score criteria must have between 2 and {SCORE_MAX_LEVELS} levels, got {}", c.len())));
}
legend = Some(c.clone());
(false, (0..c.len()).map(|i| i.to_string()).collect(), c.iter().map(ostr).collect())
}
_ => return Err(ApiError::new(422, format!("question {key}: unsupported type"))),
};
let instr = q.get("instructions").map(ostr).unwrap_or_default();
let b = st
.tok
.build(&stxt, &instr, &opts, n_images)
.map_err(|e| ApiError::new(422, format!("bad request: {e}")))?
.ok_or_else(|| ApiError::new(422, format!("question {key}: too many options per choice (max {})", st.tok.labels.len())))?;
let reserve = think_reserve(&st.pol, b.ids.len() + (img_soft_max() + 2) * n_images, n_images);
let need = b.ids.len() + (img_soft_max() + 2) * n_images + reserve + 3;
if need > st.max_len {
return Err(ApiError::context(format!("This model's maximum context length is {} tokens. However, the request needs about {} tokens (prompt + thinking budget {}).", st.max_len, need, reserve)));
}
items.push(Item { key: key.clone(), noul, keys, ids: b.ids, label_ids: b.label_ids, ..Default::default() });
legends.push(legend);
}
Ok((items, legends))
}
async fn systemone(State(st): State<Arc<AppState>>, headers: HeaderMap, body: axum::body::Body) -> HttpResponse {
let t0 = std::time::Instant::now();
if let Some(k) = &st.api_key {
let ok = headers.get(header::AUTHORIZATION).and_then(|h| h.to_str().ok()).map_or(false, |h| constant_eq(h.as_bytes(), format!("Bearer {k}").as_bytes()));
if !ok {
return err(ApiError::new(401, "invalid or missing bearer token"));
}
}
let too_big = || err(ApiError::new(413, "request body too large (maximum context length / body size exceeded)"));
if headers.get(header::CONTENT_LENGTH).and_then(|h| h.to_str().ok()).and_then(|h| h.parse::<usize>().ok()).map_or(false, |n| n > st.max_body) {
return too_big();
}
let body: Bytes = match axum::body::to_bytes(body, st.max_body).await {
Ok(b) => b,
Err(_) => return too_big(),
};
match handle(&st, &headers, body, t0).await {
Ok(r) => ok_json(&r),
Err(e) => err(e),
}
}
async fn handle(st: &Arc<AppState>, headers: &HeaderMap, body: Bytes, t0: std::time::Instant) -> Result<Response, ApiError> {
let body = assets::decode_encoding(headers, body.to_vec(), st.max_body)?;
let mut body = assets::split_body(headers, body).await?;
let req: Request = parse_request(&body.json)?;
// images in the order the model sees them: the DI kit `images` (as before), then the assets in the order sent, labelled "Image k" in the text
let mut images = decode_images(&req.images)?;
let assets = assets::resolve_assets(&req.assets, &mut body.parts)?;
if images.len() + assets.len() > assets::MAX_IMAGES {
return Err(if assets.is_empty() { ApiError::new(422, "images must be a list of at most 4 data URIs") } else { ApiError::asset(format!("at most {} images per request (images + assets), got {}", assets::MAX_IMAGES, images.len() + assets.len())) });
}
let rewritten = if assets.is_empty() {
None
} else {
let map: Vec<(String, String)> = assets.iter().enumerate().map(|(i, (ph, _))| (ph.clone(), assets::image_label(images.len() + i))).collect();
Some(assets::substitute(&req.state, &req.questions, &map))
};
let has_assets = !assets.is_empty();
images.extend(assets.into_iter().map(|(_, b)| b));
let soft: Vec<usize> = images.iter().map(|b| image_soft_tokens(b)).collect();
let rpol = request_policy(&st.pol, &req)?;
let (items, mut legends) = match &rewritten {
None => build_questions(st, &req, images.len())?,
Some((s, q)) => build_prompts(st, s, q, images.len())?,
};
// a score legend echoes the levels as sent (placeholders, not their labels)
for ((_, q), l) in req.questions.iter().zip(legends.iter_mut()) {
if let (Some(l), Some(c)) = (l.as_mut(), q.get("criteria").and_then(|c| c.as_array())) {
*l = c.clone();
}
}
let st2 = st.clone();
let items2 = items.clone();
let res = tokio::task::spawn_blocking(move || {
dllm_core::serving::set_arrival(t0);
st2.backend.run(&items2, &images, &rpol)
})
.await;
let res = match res {
Ok(Ok(r)) if r.len() == items.len() => r,
Ok(Ok(_)) => return Err(ApiError::new(500, "inference failed: result count mismatch")),
Ok(Err(e)) => {
let e = backend_error(&format!("{e:#}"));
return Err(if has_assets && e.code == 422 { ApiError::asset(e.msg) } else { e });
}
Err(e) => return Err(ApiError::new(500, format!("inference failed: {e}"))),
};
let mut answers = OrderedMap::new();
let mut usage = Usage::default();
let img_rows: usize = soft.iter().map(|n| n + 2).sum();
for ((it, r), legend) in items.iter().zip(&res).zip(&legends) {
let a = if it.number {
number_answer(&st.tok, r)
} else {
let p0 = policy::softmax(&r.p0);
let pt = r.think.as_ref().map(|z| policy::softmax(z));
match policy::finish(&it.keys, it.noul, &p0, pt.as_deref()) {
Answer::Noul(x) => AnswerOut::Noul { noul: x },
Answer::Choice { probs, order, .. } if legend.is_some() => score_answer(&order, &probs, legend.as_ref().unwrap()),
Answer::Choice { choice, probs, order } => AnswerOut::Choice { confidence: choice_confidence(probs[&choice], order.len()), choice, probabilities: OrderedMap(order.iter().map(|k| (k.clone(), probs[k])).collect()) },
}
};
let reasoning = r.think.as_ref().map(|_| ReasoningOut { tokens: r.trace_tokens, trace: rpol.return_trace.then(|| st.tok.decode_text(&r.trace_ids)) });
answers.insert(it.key.clone(), wire::Answer { answer: a, reasoning });
usage.input_tokens += it.ids.len() + img_rows;
usage.reasoning_tokens += r.trace_tokens;
usage.output_tokens += r.answer_ids.len();
}
usage.latency_ms = t0.elapsed().as_secs_f64() * 1e3;
let image_tokens = if soft.is_empty() { None } else { Some(soft.iter().sum()) };
// like TypeSafe (reports the versioned id even for an alias): the served model id, whatever the request named
Ok(Response { model: st.served_name.clone(), answers, usage, image_tokens })
}
/// soft tokens this image becomes under the current budget (Gemma-4 aspect-preserving resize; header-only probe); the budget if the header is unreadable
fn image_soft_tokens(bytes: &[u8]) -> usize {
let cfg = dllm_vision::PreprocConfig { max_soft_tokens: img_soft_max(), ..Default::default() };
dllm_vision::probe(bytes).ok().and_then(|(w, h)| dllm_vision::n_soft_for_size(w, h, &cfg).ok()).unwrap_or_else(img_soft_max)
}
/// number answer: the generated text, its value (None if it does not parse as a number) and confidence = exp(mean log-probability of the answer tokens)
fn number_answer(tok: &Tok, r: &ItemResult) -> AnswerOut {
let text = tok.decode_text(&r.answer_ids).trim().to_string();
let number = parse_number(&text);
let confidence = if r.answer_ids.is_empty() { 0.0 } else { (r.answer_logprob / r.answer_ids.len() as f64).exp() as f32 };
AnswerOut::Number { number, text, confidence }
}
/// a decimal number as the model writes it (training used `str()` of the value); thousands separators and surrounding spaces are tolerated
pub fn parse_number(text: &str) -> Option<f64> {
let t: String = text.trim().chars().filter(|&c| c != ',' && c != ' ').collect();
t.parse::<f64>().ok().filter(|v| v.is_finite())
}
fn score_answer(order: &[String], probs: &std::collections::BTreeMap<String, f32>, legend: &[Value]) -> AnswerOut {
let p: Vec<f32> = order.iter().map(|k| probs[k]).collect();
let score = p.iter().enumerate().map(|(i, &q)| i as f32 * q).sum();
AnswerOut::Score {
score,
legend: OrderedMap(legend.iter().enumerate().map(|(i, v)| (i.to_string(), v.clone())).collect()),
probabilities: OrderedMap(order.iter().cloned().zip(p.iter().cloned()).collect()),
confidence: score_confidence(&p),
}
}
fn ok_json<T: serde::Serialize>(v: &T) -> HttpResponse {
match serde_json::to_vec(v) {
Ok(b) => (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], b).into_response(),
Err(e) => err(ApiError::new(500, format!("serialisation failed: {e}"))),
}
}
/// simd-json fast path (bodies are dominated by base64 image strings); serde_json re-parse only on failure, to classify the error (400 invalid JSON / 422 bad schema)
fn parse_request(body: &[u8]) -> Result<Request, ApiError> {
let mut buf = body.to_vec();
if let Ok(r) = simd_json::serde::from_slice::<Request>(&mut buf) {
return Ok(r);
}
match serde_json::from_slice::<Request>(body) {
Ok(r) => Ok(r),
Err(e) if e.is_syntax() || e.is_eof() => Err(ApiError::new(400, "invalid JSON body")),
Err(e) => Err(ApiError::new(422, format!("bad request: {e}"))),
}
}
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0
}
// ================================================================================================================ command line + engine
/// span-02 server: Gemma-4 26B-A4B decision model on the custom Rust/CUDA engine (sm_120a)
#[derive(clap::Parser, Debug, Clone)]
#[command(about = "span-02 decision server: POST /v1/systemone, GET /health")]
pub struct Args {
#[arg(long, default_value = "127.0.0.1:8000")]
pub bind: String,
/// model directory: config.json, model.safetensors (engine-native quantised checkpoint), tokenizer.json, draft_vocab_16384.json
#[arg(long)]
pub model: String,
/// tokenizer.json (default: <model>/tokenizer.json)
#[arg(long)]
pub tokenizer: Option<String>,
/// MTP draft for speculative decoding of thoughts: directory of google/gemma-4-26B-A4B-it-assistant
#[arg(long)]
pub spec_draft: String,
/// pruned draft vocabulary (default: <model>/draft_vocab_16384.json)
#[arg(long)]
pub spec_vocab: Option<String>,
/// speculative step for up to N simultaneously thinking branches (1..=16; more branches use the plain batched greedy step)
#[arg(long, default_value_t = 4)]
pub spec_max_branches: usize,
/// bearer token every request must carry (none = open)
#[arg(long, env = "AR_API_KEY")]
pub api_key: Option<String>,
/// model id reported in every response's `model` field (the request's `model` is accepted and ignored)
#[arg(long, default_value = "span-02")]
pub served_name: String,
/// think when the no-think answer's top probability is below tau
#[arg(long, default_value_t = 0.8)]
pub tau: f32,
/// hard maximum of a thought in tokens
#[arg(long, default_value_t = 4096)]
pub cap: usize,
/// per-request wall-clock budget (ms, 0 = off): thoughts are cut and read out before arrival + budget
#[arg(long, default_value_t = 700)]
pub budget_ms: u32,
/// deterministic thought budget (ms, 0 = off): each request's per-branch thought cap = floor((T - fixed) / per_token) from the fixed cost model of
/// --think-cost (prompt rows incl. image soft tokens, images, questions, thinking questions), clipped to --cap. No clock is read.
#[arg(long, default_value_t = 0)]
pub think_ms: u32,
/// JSON cost model for --think-ms (think_cost.json, calibrated on an RTX PRO 6000)
#[arg(long)]
pub think_cost: Option<String>,
/// context limit per question: prompt + image tokens + reserved thought tokens
#[arg(long, default_value_t = 32768)]
pub max_len: usize,
#[arg(long, default_value_t = 64)]
pub max_body_mb: usize,
/// hard row limit of one forward (workspace, ~0.45 MB per row); no-think prefill packs fill it
#[arg(long, default_value_t = 8192)]
pub max_rows: usize,
/// forward row cap while any branch is thinking or waiting for its readout (0 = --max-rows). Thinking deadlines are protected by the
/// cut-before-forward check (which uses the expected duration of the forward about to run, incl. vision-tower encodes); a larger cap
/// trades decode-step frequency for prefill throughput (round 3: 4096 vs 1024 = +17 % E3 policy req/s at c=64, text p99 unchanged)
#[arg(long, default_value_t = 4096)]
pub think_max_rows: usize,
/// KV pages (16 tokens) of the full-attention layers; default max(2500, (max_len + cap) / 16 + 1024)
#[arg(long)]
pub pages: Option<usize>,
/// KV memory budget (GB): the full-attention pool holds one max-length request plus 16K tokens, the sliding-window pool gets the rest
#[arg(long, default_value_t = 9.0)]
pub kv_gb: f64,
/// Gemma-4 image budget in soft tokens per image (max_soft_tokens: 70/140/280/560/1120)
#[arg(long, default_value_t = 280)]
pub image_soft_tokens: usize,
/// one request in the engine at a time (no cross-request batching): an answer then does not depend on concurrent load
#[arg(long)]
pub serial: bool,
/// disable the cross-request radix prefix cache (no KV reuse across requests)
#[arg(long)]
pub no_prefix_cache: bool,
}
impl Args {
/// the server's answer policy
pub fn policy(&self) -> anyhow::Result<Policy> {
let think_cost = match &self.think_cost {
Some(p) => policy::ThinkCost::from_json(&std::fs::read_to_string(p)?).map_err(|e| anyhow::anyhow!("--think-cost {p}: {e:#}"))?,
None => {
anyhow::ensure!(self.think_ms == 0, "--think-ms needs --think-cost FILE (the calibrated cost model)");
policy::ThinkCost::default()
}
};
Ok(Policy { tau: self.tau, cap: self.cap, budget_ms: self.budget_ms, think_ms: self.think_ms, think_cost, ..Policy::default() })
}
fn model_file(&self, f: &str) -> String {
std::path::Path::new(&self.model).join(f).to_string_lossy().into_owned()
}
pub fn tokenizer_path(&self) -> String {
self.tokenizer.clone().unwrap_or_else(|| self.model_file("tokenizer.json"))
}
}
/// the GPU engine as the server backend (`--serial`: one request in the engine at a time)
pub struct EngineBackend(dllm_runtime::Engine, Option<std::sync::Mutex<()>>);
impl Backend for EngineBackend {
fn run(&self, items: &[Item], images: &[Vec<u8>], pol: &Policy) -> anyhow::Result<Vec<ItemResult>> {
let _g = self.1.as_ref().map(|m| m.lock().unwrap_or_else(|e| e.into_inner()));
self.0.run(items, images, pol)
}
}
/// load the decoder, the vision tower and the MTP draft on GPU 0 and start the scheduler
pub fn start_engine(a: &Args) -> anyhow::Result<EngineBackend> {
use dllm_core::{config::{AssistantConfig, LayerKind, ModelConfig}, loader::WeightStore};
use dllm_kernels::Gpu;
use dllm_model::draft::{Draft, DraftVocab};
use dllm_model::gpu_model::{GpuModel, GpuOpts};
use dllm_runtime::{Engine, EngineConfig, SpecDecoder, TowerEncoder, VisionPrep};
anyhow::ensure!(a.image_soft_tokens > 0, "--image-soft-tokens must be > 0");
anyhow::ensure!((1..=16).contains(&a.spec_max_branches), "--spec-max-branches must be 1..=16");
let dir = std::path::PathBuf::from(&a.model);
let cfg = ModelConfig::from_file(dir.join("config.json"))?;
let st = WeightStore::open(&dir)?;
// KV pools: bytes per 16-token page of the full-attention / sliding-window layers
let pb = |k: LayerKind| -> usize { cfg.text.layers.iter().filter(|s| s.kind == k).map(|s| 2 * s.n_kv_heads * 16 * s.head_dim * 2).sum() };
let (full_pb, sl_pb) = (pb(LayerKind::Full), pb(LayerKind::Sliding));
let num_pages = a.pages.unwrap_or_else(|| 2500usize.max((a.max_len + a.cap + 15) / 16 + 1024));
let sliding_pages = ((a.kv_gb * 1e9) as usize).saturating_sub(num_pages * full_pb) / sl_pb.max(1);
anyhow::ensure!(sliding_pages >= 256, "sliding-window pool too small ({sliding_pages} pages): raise --kv-gb");
eprintln!("KV pools: {} full-attention pages ({:.2} GB, {} tokens), {} sliding-window pages ({:.2} GB)", num_pages, (num_pages * full_pb) as f64 / 1e9, num_pages * 16, sliding_pages, ((sliding_pages + 1) * sl_pb) as f64 / 1e9);
let opts = GpuOpts { max_rows: a.max_rows, num_pages, max_pos: a.max_len + 64, sliding_pages };
let gpu = Gpu::new(dllm_kernels::gpu::CudaBackend::new(0)? as Arc<dyn dllm_kernels::Backend>);
let prefix = if st.contains("model.language_model.norm.weight") { "model.language_model." } else { "model." };
let m = GpuModel::load(gpu, &st, &cfg.text, prefix, opts)?;
let tok = Arc::new(Tok::from_file(a.tokenizer_path())?);
let vc = cfg.vision.clone().ok_or_else(|| anyhow::anyhow!("config.json has no vision_config"))?;
let vis: Box<dyn dllm_runtime::VisionEncoder> = Box::new(TowerEncoder(dllm_model::vision::VisionTower::load(&m.gpu, &st, &vc, cfg.text.hidden_size)?));
let prep: Arc<dyn dllm_runtime::ImagePrep> = Arc::new(VisionPrep(dllm_vision::PreprocConfig { max_soft_tokens: a.image_soft_tokens, ..Default::default() }));
let ecfg = EngineConfig { max_len: a.max_len, prep: Some(prep), max_prefill_rows: (2 * a.max_rows).max(2048), think_max_rows: a.think_max_rows.min(a.max_rows), prefix_cache: !a.no_prefix_cache, ..Default::default() };
let draft_dir = std::path::Path::new(&a.spec_draft);
let acfg = AssistantConfig::from_file(draft_dir.join("config.json"))?;
let vocab = DraftVocab::from_json_file(a.spec_vocab.clone().unwrap_or_else(|| a.model_file("draft_vocab_16384.json")))?;
let draft = Draft::load(m.gpu.clone(), &WeightStore::open(draft_dir)?, &acfg, &cfg.text, &vocab, a.max_len + 64)?;
let spec = dllm_model::spec::Spec::new(&m, draft, a.max_len + 64, 256)?;
eprintln!("speculative decoding: draft {} (vocab {}), adaptive k, spec step for up to {} thinking branches", a.spec_draft, vocab.len(), a.spec_max_branches);
let eng = Engine::start_with(ecfg, Box::new(m), Some(vis), tok, Box::new(SpecDecoder::new(spec, a.spec_max_branches)))?;
eprintln!("image budget {} soft tokens/image, serial {}, prefix cache {}", a.image_soft_tokens, a.serial, !a.no_prefix_cache);
Ok(EngineBackend(eng, a.serial.then(|| std::sync::Mutex::new(()))))
}
/// the server state with the GPU engine (also used by the GPU tests, in process)
pub fn engine_state(a: &Args) -> anyhow::Result<Arc<AppState>> {
dllm_core::tokenizer::set_img_soft_max(a.image_soft_tokens);
let pol = a.policy()?;
if a.think_ms > 0 {
eprintln!("think budget: deterministic {} ms, hard cap {}, cost model {}", a.think_ms, a.cap, serde_json::to_string(&pol.think_cost)?);
}
let be = start_engine(a)?;
Ok(Arc::new(AppState { tok: Tok::from_file(a.tokenizer_path())?, backend: Box::new(be), pol, api_key: a.api_key.clone(), max_len: a.max_len, max_body: a.max_body_mb << 20, served_name: a.served_name.clone() }))
}
/// start the engine, warm it up and serve until the process ends
pub async fn serve(a: Args) -> anyhow::Result<()> {
let st = engine_state(&a)?;
warmup(&st);
let l = tokio::net::TcpListener::bind(&a.bind).await?;
eprintln!("listening on {}", a.bind);
axum::serve(l, app(st)).await?;
Ok(())
}
/// compile / allocate / capture every shape family once (text, many questions, noul, images, forced short thought) before accepting traffic
pub fn warmup(st: &Arc<AppState>) {
let t0 = std::time::Instant::now();
let mk = |n: usize, k: usize| -> Value { json!((0..n).map(|i| (format!("q{i}"), json!({"type": "choice", "instructions": format!("Which item number {i}?"), "criteria": (0..k).map(|j| (format!("o{j}"), json!(format!("item {j}")))).collect::<serde_json::Map<_, _>>()}))).collect::<serde_json::Map<_, _>>()) };
let mut reqs = vec![
json!({"state": "The color is red.", "questions": mk(1, 2)}),
json!({"state": {"a": (0..300).collect::<Vec<_>>()}, "questions": mk(12, 5)}),
json!({"state": "y", "questions": {"n": {"type": "noul", "instructions": "Is it red?"}}}),
json!({"state": {"a": 3, "b": 4}, "questions": {"s": {"type": "number", "instructions": "What is a + b?"}}}),
];
// image path: a small synthetic PNG (upscaled by the preprocessor to the full patch budget, i.e. the biggest tower shape), one and two images
const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAABl0lEQVR4nN3TwQrCMBCE4RVTelDf/3U9CCJa2zSTZGYWcg38fMNeIuK2xN32PZax/eUaa4lofkvEDfiOvyv2/bi/YEDD++i+INDwProvAmSwP96PABnsj/cTT8zDtxnIY3+8vxnIY3+8n3ViNr5tQDb74/1tQDb74/2UE3PybQBy2h/vbwBy2h/vn39iZr5ngcz2x/vPApntj/dPPjE/31NAfvvj/aeA/PbH+2eemKVvPZDl/nh/PZDl/nj/tBNz9a0Ect0f768Ect0f759zYsa+NUDG++P9NUDG++P9E07M2/cQyHt/vP8QyHt/uH/4ibn7HgDR+/i++0D8Prbv2BPL4LsDJNFH990Bkuij+447sSS+/4BU+ui+/4BU+ui+g04sj+8mkFAf3XcTSKiP7jvixFL5/gJp9dF9f4G0+ui+3U8sm+8XkFwf3fcLSK6P7tv3xBL6fgIp9tF9P4EU++i+HU8sp+8bSLSP7vsGEu2j+/Y6sbS+LyDdPrrvC0i3j+7b5cQy+5ZYpfvoviVW6T66L35iuX2f8mlqD6Wvw2UAAAAASUVORK5CYII=";
let img = format!("data:image/png;base64,{PNG_B64}");
reqs.push(json!({"state": "x", "images": [img.clone()], "questions": mk(2, 4)}));
reqs.push(json!({"state": "x", "images": [img.clone(), img], "questions": mk(1, 3)}));
for tau in [0.8f32, 1.01] {
for r in &reqs {
let Ok(req) = serde_json::from_value::<wire::Request>(r.clone()) else { continue };
let Ok(imgs) = decode_images(&req.images) else { continue };
let Ok(items) = build_items(st, &req, imgs.len()) else { continue };
let pol = Policy { tau, cap: if tau > 1.0 { 24 } else { st.pol.cap }, ..st.pol };
if let Err(e) = st.backend.run(&items, &imgs, &pol) {
eprintln!("warmup request failed: {e:#}");
}
}
}
eprintln!("warmup done in {:.1}s", t0.elapsed().as_secs_f32());
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use tower::ServiceExt;
#[test]
fn kv_capacity_is_400_max_tokens_exceeded_with_di_marker_and_transient_shortage_is_529() {
let cap = backend_error("KV pool exhausted: KV cache capacity exceeded (3652 pages needed, 2500 pages total)");
assert_eq!((cap.code, cap.kind), (400, "max_tokens_exceeded"));
assert!(cap.msg.contains("maximum context length"), "{}", cap.msg);
let sw = backend_error("sliding-window KV pool exhausted: KV cache capacity exceeded (3000 pages needed, 2496 pages total)");
assert_eq!(sw.code, 400);
let tr = backend_error("KV pool exhausted (2500 pages total, 300 needed, 100 free)");
assert_eq!((tr.code, tr.kind), (529, "overloaded"));
assert!(!tr.msg.contains("maximum context length"));
assert_eq!(backend_error("boom").code, 500);
}
/// uniform distributions, never thinks
struct UniformBackend;
impl Backend for UniformBackend {
fn run(&self, items: &[Item], _images: &[Vec<u8>], _pol: &Policy) -> anyhow::Result<Vec<ItemResult>> {
Ok(items.iter().map(|it| ItemResult { p0: vec![1.0 / it.keys.len() as f32; it.keys.len()], think: None, trace_tokens: 0, out_tokens: 1, ..Default::default() }).collect())
}
}
/// deterministic pseudo-model: logits are a hash of the prompt ids; questions whose max prob < tau "think" (post-thought logits = another hash)
struct HashBackend;
impl Backend for HashBackend {
fn run(&self, items: &[Item], _images: &[Vec<u8>], pol: &Policy) -> anyhow::Result<Vec<ItemResult>> {
fn logits(ids: &[u32], n: usize, salt: u64) -> Vec<f32> {
let mut h = 0xcbf29ce484222325u64 ^ salt;
for &t in ids {
h = (h ^ t as u64).wrapping_mul(0x100000001b3);
}
(0..n)
.map(|i| {
let mut x = h ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15);
x ^= x >> 33;
x = x.wrapping_mul(0xff51afd7ed558ccd);
x ^= x >> 29;
((x % 6000) as f32 / 1000.0) - 1.0
})
.collect()
}
Ok(items
.iter()
.map(|it| {
let n = it.keys.len();
let p0 = logits(&it.ids, n, 1);
let think = (!it.number && pol.gate != policy::GateMode::Never && policy::needs_think(&policy::softmax(&p0), pol.tau)).then(|| logits(&it.ids, n, 2));
let tt = if think.is_some() { 7.min(it.ids.len()) } else { 0 };
// fake trace = the last `tt` prompt ids (decodable text) when asked for
let trace_ids = if pol.return_trace { it.ids[it.ids.len() - tt..].to_vec() } else { vec![] };
ItemResult { p0, think, trace_tokens: tt, out_tokens: 1 + tt, trace_ids, ..Default::default() }
})
.collect())
}
}
fn state(key: Option<&str>) -> Option<Arc<AppState>> {
state_with(key, Box::new(UniformBackend))
}
fn state_with(key: Option<&str>, backend: Box<dyn Backend>) -> Option<Arc<AppState>> {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../tokenizer.json");
Some(Arc::new(AppState { tok: Tok::from_file(p).unwrap(), backend, pol: Policy::default(), api_key: key.map(String::from), max_len: 8192, max_body: 1 << 20, served_name: "span-02".into() }))
}
async fn post_json(app: Router, body: Value, auth: Option<&str>) -> (u16, Value) {
let mut r = axum::http::Request::post("/v1/systemone").header("content-type", "application/json");
if let Some(a) = auth {
r = r.header("authorization", a);
}
let resp = app.oneshot(r.body(Body::from(body.to_string())).unwrap()).await.unwrap();
let code = resp.status().as_u16();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(code, serde_json::from_slice(&b).unwrap())
}
#[tokio::test]
async fn wire_format_and_errors() {
let Some(st) = state(Some("k")) else { return };
let app = app(st);
let body = json!({"model": "x", "state": {"hp": 7}, "questions": {
"act": {"type": "choice", "instructions": "next?", "criteria": {"up": "move up", "down": "move down", "wait": "wait"}},
"ok": {"type": "noul", "instructions": "healthy?"}}});
let (c, v) = post_json(app.clone(), body.clone(), Some("Bearer k")).await;
assert_eq!(c, 200, "{v}");
// exactly what decision_index.engines.base.validate checks
let a = &v["answers"]["act"];
assert_eq!(a["type"], "choice");
let p = a["probabilities"].as_object().unwrap();
assert_eq!(p.len(), 3);
assert!((p.values().map(|x| x.as_f64().unwrap()).sum::<f64>() - 1.0).abs() < 0.01);
assert!(p.contains_key(a["choice"].as_str().unwrap()));
assert_eq!(v["answers"]["ok"]["type"], "noul");
assert!((v["answers"]["ok"]["noul"].as_f64().unwrap() - 0.5).abs() < 1e-6);
assert!(a.get("reasoning").is_none(), "no reasoning field on an answer that did not think");
assert_eq!(v["usage"].as_object().unwrap().keys().collect::<Vec<_>>(), ["input_tokens", "reasoning_tokens", "output_tokens", "latency_ms"]);
assert_eq!((v["usage"]["reasoning_tokens"].as_u64(), v["usage"]["output_tokens"].as_u64()), (Some(0), Some(0)));
let (c, e) = post_json(app.clone(), body.clone(), None).await;
assert_eq!((c, &e), (401, &json!({"error": {"type": "authentication_error", "message": "invalid or missing bearer token"}})));
let (c, e) = post_json(app.clone(), json!({"state": 1, "questions": {}}), Some("Bearer k")).await;
assert_eq!((c, e["error"]["type"].as_str()), (422, Some("validation_error")));
assert_eq!(post_json(app.clone(), json!({"state": 1, "questions": {"q": {"type": "weird"}}}), Some("Bearer k")).await.0, 422);
assert_eq!(post_json(app.clone(), json!({"state": 1, "images": ["data:image/png;base64,@@"], "questions": {"q": {"type": "noul", "instructions": "x"}}}), Some("Bearer k")).await.0, 422);
// the old alias route is gone: 404 in the error format
let r = axum::http::Request::post("/v1/decisions").header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
let resp = app.clone().oneshot(r).await.unwrap();
assert_eq!(resp.status().as_u16(), 404);
let e: Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
assert_eq!(e["error"]["type"], "not_found");
let big = json!({"state": "x".repeat(200_000), "questions": {"q": {"type": "noul", "instructions": "x"}}});
let (c, v) = post_json(app, big, Some("Bearer k")).await;
assert!(c == 400 || c == 413, "{c} {v}");
}
#[tokio::test]
async fn think_override_and_traces() {
let Some(st) = state_with(None, Box::new(HashBackend)) else { return };
let app = app(st);
let qs = json!((0..16).map(|i| (format!("q{i}"), json!({"type": "choice", "instructions": format!("pick {i}"), "criteria": {"a": "alpha", "b": "beta", "c": "gamma"}}))).collect::<serde_json::Map<_, _>>());
let body = |extra: Value| {
let mut b = json!({"state": {"hp": 7}, "questions": qs.clone()});
b.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
b
};
let thought = |v: &Value| v["answers"].as_object().unwrap().values().filter(|a| a.get("reasoning").is_some()).count();
let traces = |v: &Value| v["answers"].as_object().unwrap().values().filter(|a| a["reasoning"].get("trace").is_some()).count();
let strip = |v: &Value| {
let mut a = v["answers"].clone();
a.as_object_mut().unwrap().values_mut().for_each(|x| drop(x.as_object_mut().unwrap().remove("reasoning")));
a
};
let (c, auto) = post_json(app.clone(), body(json!({})), None).await;
assert_eq!(c, 200);
let na = thought(&auto);
assert!(na > 0 && na < 16, "hash backend should split at tau 0.8: {na}");
assert_eq!(traces(&auto), 0, "no trace unless asked");
let rt: u64 = auto["answers"].as_object().unwrap().values().filter_map(|a| a["reasoning"]["tokens"].as_u64()).sum();
assert!(rt > 0 && auto["usage"]["reasoning_tokens"].as_u64() == Some(rt));
// mode "auto" == no field; unknown top-level fields (incl. the removed aliases think / budget_ms / return_trace) are ignored
for x in [json!({"reasoning": {"mode": "auto"}}), json!({"reasoning": {}}), json!({"reasoning": null}), json!({"think": "on", "return_trace": true, "budget_ms": 5})] {
let (c, v) = post_json(app.clone(), body(x.clone()), None).await;
assert_eq!((c, &v["answers"]), (200, &auto["answers"]), "{x}");
}
for x in [json!({"reasoning": {"mode": "on", "trace": true}}), json!({"reasoning": {"mode": "on", "trace": true, "budget_ms": 0}})] {
let (_, on) = post_json(app.clone(), body(x.clone()), None).await;
assert_eq!((thought(&on), traces(&on)), (16, 16), "{x}");
assert!(on["answers"].as_object().unwrap().values().all(|a| !a["reasoning"]["trace"].as_str().unwrap().is_empty()));
}
let (_, off) = post_json(app.clone(), body(json!({"reasoning": {"mode": "off", "trace": true}})), None).await;
assert_eq!((thought(&off), off["usage"]["reasoning_tokens"].as_u64()), (0, Some(0)));
// traces only for questions that thought (auto); answers unchanged by asking for them
let (_, at) = post_json(app.clone(), body(json!({"reasoning": {"trace": true}})), None).await;
assert_eq!((thought(&at), traces(&at)), (na, na));
assert_eq!(strip(&at), strip(&auto));
for bad in [json!({"reasoning": {"mode": "maybe"}}), json!({"reasoning": {"mode": true}}), json!({"reasoning": {"trace": "yes"}}), json!({"reasoning": {"budget_ms": -1}}),
json!({"reasoning": {"modes": "on"}}), json!({"reasoning": "on"})] {
let (c, v) = post_json(app.clone(), body(bad.clone()), None).await;
assert_eq!((c, v["error"]["type"].as_str()), (422, Some("validation_error")), "{bad} {v}");
}
}
#[tokio::test]
async fn budget_ms_field() {
let base = Policy { budget_ms: 700, think_ms: 0, ..Policy::default() };
let req = |extra: Value| -> Request {
let mut b = json!({"state": 1, "questions": {"q": {"type": "noul", "instructions": "x"}}});
b.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
serde_json::from_value(b).unwrap()
};
let bud = |extra: Value| request_policy(&base, &req(extra)).map(|p| p.budget_ms).map_err(|e| e.code);
assert_eq!(bud(json!({})), Ok(700), "server budget by default");
assert_eq!(bud(json!({"reasoning": {"mode": "auto"}})), Ok(700));
assert_eq!(bud(json!({"budget_ms": 0, "think": "on"})), Ok(700), "top-level fields are not reasoning controls");
assert_eq!(bud(json!({"reasoning": {"mode": "on"}})), Ok(0), "mode on without budget_ms: full thought");
assert_eq!(bud(json!({"reasoning": {"budget_ms": 1200.4}})), Ok(1200));
assert_eq!(bud(json!({"reasoning": {"budget_ms": -1}})), Err(422));
assert_eq!(bud(json!({"reasoning": {"mode": "on", "budget_ms": 2500}})), Ok(2500));
assert_eq!(bud(json!({"reasoning": {"budget_ms": 0}})), Ok(0));
assert_eq!(bud(json!({"reasoning": {"budget_ms": 600001}})), Err(422));
// think-ms (deterministic cap) is untouched by budget_ms
let det = Policy { budget_ms: 0, think_ms: 750, ..Policy::default() };
let p = request_policy(&det, &req(json!({"reasoning": {"mode": "on", "budget_ms": 300}}))).ok().unwrap();
assert_eq!((p.think_ms, p.budget_ms), (750, 300));
// over HTTP: a bad value is a 422, a string is a 422 schema error
let Some(st) = state_with(None, Box::new(HashBackend)) else { return };
let app = app(st);
let body = |b: Value| json!({"state": 1, "questions": {"q": {"type": "noul", "instructions": "x"}}, "reasoning": {"budget_ms": b}});
assert_eq!(post_json(app.clone(), body(json!(-5)), None).await.0, 422);
assert_eq!(post_json(app.clone(), body(json!("100")), None).await.0, 422);
assert_eq!(post_json(app, body(json!(0)), None).await.0, 200);
}
#[tokio::test]
async fn number_questions() {
assert_eq!(parse_number("391"), Some(391.0));
assert_eq!(parse_number(" -12.5 "), Some(-12.5));
assert_eq!(parse_number("1,250"), Some(1250.0));
assert_eq!(parse_number("1e-05"), Some(1e-5));
assert_eq!(parse_number("about 3"), None);
assert_eq!(parse_number(""), None);
let Some(st) = state_with(None, Box::new(HashBackend)) else { return };
// the prompt is the trained number prompt: no OPTIONS block, "Answer with the number only."
let req: Request = serde_json::from_value(json!({"state": {"a": 3}, "questions": {"n": {"type": "number", "instructions": "a + 4?"}, "c": {"type": "choice", "instructions": "x", "criteria": {"a": "1", "b": "2"}}}})).unwrap();
let (items, _) = build_questions(&st, &req, 0).ok().unwrap();
assert!(items[0].number && items[0].keys.is_empty() && items[0].label_ids.is_empty() && !items[1].number);
let text = st.tok.decode(&items[0].ids);
assert!(text.contains("exactly one number") && text.ends_with("Answer with the number only.<turn|>\n<|turn>model\n<|channel>thought\n") && !text.contains("OPTIONS"), "{text}");
let (c, v) = post_json(app(st), json!({"state": 1, "questions": {"n": {"type": "number", "instructions": "how many?"}}}), None).await;
assert_eq!(c, 200, "{v}");
assert_eq!(v["answers"]["n"].as_object().unwrap().keys().collect::<Vec<_>>(), ["type", "number", "text", "confidence"]);
assert_eq!(v["answers"]["n"]["type"], "number");
}
#[test]
fn score_confidence_typesafe_examples() {
assert!((score_confidence(&[0.0, 0.5, 0.5]) - 0.25).abs() < 1e-6);
assert_eq!(score_confidence(&[0.5, 0.0, 0.5]), 0.0);
assert!((score_confidence(&[0.0, 0.57, 0.43]) - 0.355).abs() < 1e-5);
assert!((score_confidence(&[0.0, 0.0, 1.0]) - 1.0).abs() < 1e-6);
assert!((score_confidence(&[0.25; 4]) - 0.0).abs() < 1e-6);
assert!((score_confidence(&[0.0, 0.05, 0.9, 0.05, 0.0]) - (1.0 - 0.1 / 1.2)).abs() < 1e-5); // docs' explorer default ~0.92
}
#[tokio::test]
async fn score_parsing_and_shape() {
let Some(st) = state_with(None, Box::new(HashBackend)) else { return };
let app = app(st);
let levels = json!(["calm", {"desc": "annoyed", "w": 1}, ["angry", 2]]);
let body = json!({"state": {"msg": "hi"}, "questions": {
"c": {"type": "choice", "instructions": "pick", "criteria": {"a": "alpha", "b": "beta"}},
"s": {"type": "score", "instructions": "how frustrated?", "criteria": levels.clone()},
"n": {"type": "noul", "instructions": "ok?"}}});
let (c, v) = post_json(app.clone(), body, None).await;
assert_eq!(c, 200, "{v}");
assert_eq!(v["answers"].as_object().unwrap().keys().collect::<Vec<_>>(), ["c", "s", "n"]);
let s = &v["answers"]["s"];
assert_eq!(s.as_object().unwrap().keys().collect::<Vec<_>>(), ["type", "score", "legend", "probabilities", "confidence"]);
assert_eq!(s["type"], "score");
assert_eq!(s["legend"], json!({"0": "calm", "1": {"desc": "annoyed", "w": 1}, "2": ["angry", 2]}));
let p = s["probabilities"].as_object().unwrap();
assert_eq!(p.keys().collect::<Vec<_>>(), ["0", "1", "2"]);
let pv: Vec<f32> = p.values().map(|x| x.as_f64().unwrap() as f32).collect();
assert!((pv.iter().sum::<f32>() - 1.0).abs() < 1e-3);
let sc = s["score"].as_f64().unwrap() as f32;
assert!((sc - (pv[1] + 2.0 * pv[2])).abs() < 1e-4 && (0.0..=2.0).contains(&sc));
assert!((s["confidence"].as_f64().unwrap() as f32 - score_confidence(&pv)).abs() < 1e-6);
assert!(v["answers"]["c"].get("confidence").is_some());
// score runs exactly as a choice question over the same options (same prompt -> same probabilities)
let as_choice = json!({"state": {"msg": "hi"}, "questions": {"s": {"type": "choice", "instructions": "how frustrated?", "criteria": {"0": "calm", "1": {"desc": "annoyed", "w": 1}, "2": ["angry", 2]}}}});
let (_, vc) = post_json(app.clone(), as_choice, None).await;
assert_eq!(vc["answers"]["s"]["probabilities"], s["probabilities"]);
for bad in [json!(["one"]), json!((0..11).map(|i| format!("l{i}")).collect::<Vec<_>>()), json!({"0": "a", "1": "b"}), json!("a"), json!([])] {
let (c, v) = post_json(app.clone(), json!({"state": 1, "questions": {"s": {"type": "score", "instructions": "x", "criteria": bad}}}), None).await;
assert_eq!(c, 422, "{v}");
}
let (c, _) = post_json(app.clone(), json!({"state": 1, "questions": {"s": {"type": "score", "instructions": "x"}}}), None).await;
assert_eq!(c, 422);
let ten = json!((0..10).map(|i| format!("l{i}")).collect::<Vec<_>>());
let (c, v) = post_json(app, json!({"state": 1, "questions": {"s": {"type": "score", "instructions": "x", "criteria": ten}}}), None).await;
assert_eq!(c, 200, "{v}");
assert_eq!(v["answers"]["s"]["probabilities"].as_object().unwrap().len(), 10);
}
#[test]
fn choice_confidence_typesafe_example() {
assert!((choice_confidence(0.6, 3) - 0.4).abs() < 1e-6);
assert_eq!(choice_confidence(1.0, 1), 1.0);
assert!((choice_confidence(1.0 / 3.0, 3)).abs() < 1e-6);
assert!((choice_confidence(1.0, 4) - 1.0).abs() < 1e-6);
assert!((choice_confidence(0.9, 3) - 0.85).abs() < 1e-6);
}
#[tokio::test]
async fn official_fields_noul_criteria_model() {
let Some(st) = state_with(None, Box::new(HashBackend)) else { return };
let app = app(st.clone());
let q = |c: Option<Value>| {
let mut n = json!({"type": "noul", "instructions": "refund requested?"});
if let Some(c) = c {
n["criteria"] = c;
}
json!({"model": "jev-latest", "state": "I want my money back", "questions": {"n": n, "c": {"type": "choice", "instructions": "tone", "criteria": {"calm": null, "angry": "upset"}}}})
};
let (c, plain) = post_json(app.clone(), q(None), None).await;
assert_eq!(c, 200, "{plain}");
assert_eq!(plain["model"], "span-02");
assert_eq!(plain["answers"]["n"].as_object().unwrap().keys().collect::<Vec<_>>(), ["type", "noul"]);
let ch = &plain["answers"]["c"];
assert_eq!(ch.as_object().unwrap().keys().collect::<Vec<_>>(), ["type", "choice", "confidence", "probabilities"]);
let pm = ch["probabilities"][ch["choice"].as_str().unwrap()].as_f64().unwrap() as f32;
assert!((ch["confidence"].as_f64().unwrap() as f32 - choice_confidence(pm, 2)).abs() < 1e-6);
// the default descriptions are exactly today's rendering; null criteria == absent
let (_, dflt) = post_json(app.clone(), q(Some(json!({"false": "False", "true": "True"}))), None).await;
assert_eq!(dflt["answers"], plain["answers"]);
let (_, nul) = post_json(app.clone(), q(Some(Value::Null)), None).await;
assert_eq!(nul["answers"], plain["answers"]);
let mk = |c: Value| build_questions(&st, &serde_json::from_value(q(Some(c))).unwrap(), 0).map(|x| x.0[0].ids.clone());
let base = build_questions(&st, &serde_json::from_value(q(None)).unwrap(), 0).ok().unwrap().0[0].ids.clone();
assert_eq!(mk(json!({"true": null, "false": null})).ok().unwrap(), base);
// DI rows send `"criteria": {}` on noul questions (calibration-v1): same as absent, not a 422
assert_eq!(mk(json!({})).ok().unwrap(), base);
assert_ne!(mk(json!({"true": "A refund is requested", "false": {"no": ["refund"]}})).ok().unwrap(), base);
let (c, _) = post_json(app.clone(), q(Some(json!({"true": "A refund is requested", "false": {"no": ["refund"]}}))), None).await;
assert_eq!(c, 200);
for bad in [json!({"true": "x"}), json!({"true": "x", "false": "y", "maybe": "z"}), json!(["x", "y"]), json!("x"), json!({"yes": "x", "no": "y"})] {
let (c, v) = post_json(app.clone(), q(Some(bad)), None).await;
assert_eq!(c, 422, "{v}");
}
// choice null description renders as its key
let ids = |cr: Value| build_questions(&st, &serde_json::from_value(json!({"state": 1, "questions": {"c": {"type": "choice", "instructions": "t", "criteria": cr}}})).unwrap(), 0).ok().unwrap().0[0].ids.clone();
assert_eq!(ids(json!({"calm": null, "angry": "upset"})), ids(json!({"calm": "calm", "angry": "upset"})));
let mut b = q(None);
b.as_object_mut().unwrap().remove("model");
assert_eq!(post_json(app, b, None).await.1["model"], "span-02");
}
const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAABl0lEQVR4nN3TwQrCMBCE4RVTelDf/3U9CCJa2zSTZGYWcg38fMNeIuK2xN32PZax/eUaa4lofkvEDfiOvyv2/bi/YEDD++i+INDwProvAmSwP96PABnsj/cTT8zDtxnIY3+8vxnIY3+8n3ViNr5tQDb74/1tQDb74/2UE3PybQBy2h/vbwBy2h/vn39iZr5ngcz2x/vPApntj/dPPjE/31NAfvvj/aeA/PbH+2eemKVvPZDl/nh/PZDl/nj/tBNz9a0Ect0f768Ect0f759zYsa+NUDG++P9NUDG++P9E07M2/cQyHt/vP8QyHt/uH/4ibn7HgDR+/i++0D8Prbv2BPL4LsDJNFH990Bkuij+447sSS+/4BU+ui+/4BU+ui+g04sj+8mkFAf3XcTSKiP7jvixFL5/gJp9dF9f4G0+ui+3U8sm+8XkFwf3fcLSK6P7tv3xBL6fgIp9tF9P4EU++i+HU8sp+8bSLSP7vsGEu2j+/Y6sbS+LyDdPrrvC0i3j+7b5cQy+5ZYpfvoviVW6T66L35iuX2f8mlqD6Wvw2UAAAAASUVORK5CYII=";
const JPG_B64: &str = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAARCAAQABADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDHooorhPqD/9k=";
/// records the prompt text and image count of the last run; uniform answers
struct RecBackend(Arc<std::sync::Mutex<(Vec<String>, usize)>>, Arc<Tok>);
impl Backend for RecBackend {
fn run(&self, items: &[Item], images: &[Vec<u8>], _pol: &Policy) -> anyhow::Result<Vec<ItemResult>> {
*self.0.lock().unwrap() = (items.iter().map(|it| self.1.decode(&it.ids)).collect(), images.len());
UniformBackend.run(items, images, _pol)
}
}
fn rec_app() -> (Router, Arc<std::sync::Mutex<(Vec<String>, usize)>>) {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../tokenizer.json");
let rec = Arc::new(std::sync::Mutex::new((vec![], 0)));
let st = state_with(None, Box::new(RecBackend(rec.clone(), Arc::new(Tok::from_file(p).unwrap())))).unwrap();
(app(st), rec)
}
async fn post_raw(app: Router, body: Vec<u8>, headers: &[(&str, &str)]) -> (u16, Value) {
let mut r = axum::http::Request::post("/v1/systemone");
for (k, v) in headers {
r = r.header(*k, *v);
}
let resp = app.oneshot(r.body(Body::from(body)).unwrap()).await.unwrap();
let code = resp.status().as_u16();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(code, serde_json::from_slice(&b).unwrap_or(Value::Null))
}
#[tokio::test]
async fn assets_render_as_numbered_images_in_order() {
let (app, rec) = rec_app();
let body = json!({"model": "jev-latest", "state": {"photo": "a3f1c2e8-found"},
"assets": {"a3f1c2e8-found": {"type": "image", "format": "jpg", "data_base64": JPG_B64}, "77b0d4a1-poster": {"format": "png", "data_base64": PNG_B64}},
"questions": {"same": {"type": "noul", "instructions": "Is the dog in `photo` the same dog as in this picture: 77b0d4a1-poster?"},
"which": {"type": "choice", "instructions": "Which shows a dog?", "criteria": {"found": "a3f1c2e8-found", "poster": {"image": "77b0d4a1-poster"}}},
"s": {"type": "score", "instructions": "x", "criteria": ["a3f1c2e8-found", "b"]}}});
let (c, v) = post_json(app.clone(), body.clone(), None).await;
assert_eq!(c, 200, "{v}");
let (texts, nimg) = rec.lock().unwrap().clone();
assert_eq!(nimg, 2);
assert!(texts[0].starts_with("<bos><|turn>system\n") && texts[0].contains("<|turn>user\n<|image|><|image|>SHARED STATE (JSON string):\n{\"photo\": \"Image 1\"}"), "{}", texts[0]);
assert!(texts[0].contains("the same dog as in this picture: Image 2?"), "{}", texts[0]);
assert!(texts[1].contains("A: Image 1\nB: {\"image\": \"Image 2\"}"), "{}", texts[1]);
assert!(!texts.iter().any(|t| t.contains("a3f1c2e8") || t.contains("77b0d4a1")));
assert_eq!(v["answers"]["s"]["legend"], json!({"0": "a3f1c2e8-found", "1": "b"}), "legend echoes the placeholder");
assert!(v["image_tokens"].as_u64().unwrap() > 0);
// `images` come first: Image 1 = the data URI, the asset becomes Image 2
let b2 = json!({"state": "is asset-1 red?", "images": [format!("data:image/png;base64,{PNG_B64}")], "assets": {"asset-1": {"format": "jpeg", "data_base64": JPG_B64}}, "questions": {"q": {"type": "noul", "instructions": "x"}}});
assert_eq!(post_json(app.clone(), b2, None).await.0, 200);
let (texts, nimg) = rec.lock().unwrap().clone();
assert!(nimg == 2 && texts[0].contains("\"is Image 2 red?\""), "{}", texts[0]);
// a single `images` entry renders exactly as before (no labels)
let b3 = json!({"state": "s", "images": [PNG_B64], "questions": {"q": {"type": "noul", "instructions": "x"}}});
assert_eq!(post_json(app.clone(), b3, None).await.0, 200);
let want = Tok::from_file(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../tokenizer.json")).unwrap().build(&state_text(&json!("s")), "x", &["True".into(), "False".into()], 1).unwrap().unwrap().text;
assert_eq!(rec.lock().unwrap().0[0], want);
// referencing is optional: one unreferenced asset renders byte-identically to the same image sent via `images` (single image, no label)
let b4 = json!({"state": "s", "assets": {"photo-1": {"type": "image", "format": "png", "data_base64": PNG_B64}}, "questions": {"q": {"type": "noul", "instructions": "x"}}});
let (c, v) = post_json(app.clone(), b4, None).await;
assert_eq!(c, 200, "{v}");
let (texts, nimg) = rec.lock().unwrap().clone();
assert_eq!((texts[0].as_str(), nimg), (want.as_str(), 1));
// two assets, one referenced: both images shown in send order, only the referenced one labelled in the text
let b5 = json!({"state": "is ph red?", "assets": {"other": {"format": "png", "data_base64": PNG_B64}, "ph": {"format": "jpg", "data_base64": JPG_B64}}, "questions": {"q": {"type": "noul", "instructions": "x"}}});
assert_eq!(post_json(app.clone(), b5, None).await.0, 200);
let (texts, nimg) = rec.lock().unwrap().clone();
assert!(nimg == 2 && texts[0].contains("<|image|><|image|>") && texts[0].contains("\"is Image 2 red?\""), "{}", texts[0]);
// rejections (TypeSafe: 400 per rejected asset)
let q = json!({"q": {"type": "noul", "instructions": "about ph?"}});
for (name, assets) in [
("type", json!({"ph": {"type": "audio", "format": "png", "data_base64": PNG_B64}})),
("format", json!({"ph": {"format": "gif", "data_base64": PNG_B64}})),
("format_missing", json!({"ph": {"data_base64": PNG_B64}})),
("format_mismatch", json!({"ph": {"format": "jpg", "data_base64": PNG_B64}})),
("bad_base64", json!({"ph": {"format": "png", "data_base64": "@@@"}})),
("not_image", json!({"ph": {"format": "png", "data_base64": "aGVsbG8="}})),
("no_data", json!({"ph": {"format": "png"}})),
("missing_part", json!({"ph": {"part": "p1"}})),
("empty_placeholder", json!({" ": {"format": "png", "data_base64": PNG_B64}})),
("not_object", json!(["x"])),
("five", json!((0..5).map(|i| (format!("ph{i}"), json!({"format": "png", "data_base64": PNG_B64}))).collect::<serde_json::Map<_, _>>())),
] {
let st = if name == "five" { json!("ph0 ph1 ph2 ph3 ph4") } else { json!("s") };
let (c, v) = post_json(app.clone(), json!({"state": st, "assets": assets, "questions": q}), None).await;
assert_eq!((c, v["error"]["type"].as_str()), (400, Some("invalid_asset")), "{name}: {v}");
}
}
#[tokio::test]
async fn multipart_and_compressed_bodies() {
let (app, rec) = rec_app();
let req = json!({"state": {"photo": "<p>"}, "assets": {"<p>": {"type": "image", "part": "door-photo"}}, "questions": {"q": {"type": "noul", "instructions": "Is the door in `photo` open?"}}});
let jpg = base64_simd::STANDARD.decode_to_vec(JPG_B64).unwrap();
let mp = |parts: &[(&str, Option<&str>, &[u8])]| {
let mut b = vec![];
for (name, ct, data) in parts {
b.extend(format!("--XyZ\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{name}\"\r\n").as_bytes());
if let Some(ct) = ct {
b.extend(format!("Content-Type: {ct}\r\n").as_bytes());
}
b.extend(b"\r\n");
b.extend(*data);
b.extend(b"\r\n");
}
b.extend(b"--XyZ--\r\n");
b
};
let rj = req.to_string();
let ct = [("content-type", "multipart/form-data; boundary=XyZ")];
let (c, v) = post_raw(app.clone(), mp(&[("request", Some("application/json"), rj.as_bytes()), ("door-photo", Some("image/jpeg"), &jpg)]), &ct).await;
assert_eq!(c, 200, "{v}");
let (texts, nimg) = rec.lock().unwrap().clone();
assert!(nimg == 1 && texts[0].contains("{\"photo\": \"Image 1\"}"), "{}", texts[0]);
for (name, parts) in [
("unused_part", vec![("request", Some("application/json"), rj.as_bytes()), ("door-photo", None, &jpg[..]), ("extra", None, &jpg[..])]),
("bad_ctype", vec![("request", Some("application/json"), rj.as_bytes()), ("door-photo", Some("text/plain"), &jpg[..])]),
("no_request", vec![("door-photo", None, &jpg[..])]),
("missing_part", vec![("request", None, rj.as_bytes())]),
] {
let (c, v) = post_raw(app.clone(), mp(&parts), &ct).await;
assert_eq!(c, 400, "{name}: {v}");
}
// Content-Encoding: zstd (the TypeSafe Python SDK default) and gzip give the same answers as the plain body
let plain = json!({"state": "s", "questions": {"q": {"type": "choice", "instructions": "x", "criteria": {"a": "1", "b": "2"}}}}).to_string();
let j = [("content-type", "application/json")];
let (c0, v0) = post_raw(app.clone(), plain.clone().into_bytes(), &j).await;
assert_eq!(c0, 200);
let z = zstd::encode_all(plain.as_bytes(), 3).unwrap();
let (c, v) = post_raw(app.clone(), z, &[j[0], ("content-encoding", "zstd")]).await;
assert_eq!((c, &v["answers"]), (200, &v0["answers"]), "{v}");
let mut g = flate2::write::GzEncoder::new(vec![], flate2::Compression::default());
std::io::Write::write_all(&mut g, plain.as_bytes()).unwrap();
let (c, v) = post_raw(app.clone(), g.finish().unwrap(), &[j[0], ("content-encoding", "gzip")]).await;
assert_eq!((c, &v["answers"]), (200, &v0["answers"]), "{v}");
let (c, v) = post_raw(app.clone(), plain.clone().into_bytes(), &[j[0], ("content-encoding", "br")]).await;
assert_eq!((c, v["error"]["type"].as_str()), (415, Some("unsupported_encoding")));
let (c, _) = post_raw(app.clone(), plain.clone().into_bytes(), &[j[0], ("content-encoding", "gzip, zstd")]).await;
assert_eq!(c, 415);
let (c, v) = post_raw(app.clone(), b"not zstd".to_vec(), &[j[0], ("content-encoding", "zstd")]).await;
assert_eq!((c, v["error"]["type"].as_str()), (400, Some("invalid_request")));
let (c, v) = post_raw(app.clone(), b"{not json".to_vec(), &j).await;
assert_eq!((c, &v), (400, &json!({"error": {"type": "invalid_request", "message": "invalid JSON body"}})));
}
#[tokio::test]
async fn health_is_minimal_and_context_overflow_is_400() {
let Some(st) = state(None) else { return };
let resp = app(st.clone()).oneshot(axum::http::Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap(), json!({"ok": true, "model": "span-02"}));
let (c, v) = post_json(app(st), json!({"state": "x ".repeat(9000), "questions": {"q": {"type": "noul", "instructions": "x"}}}), None).await;
assert_eq!((c, v["error"]["type"].as_str()), (400, Some("max_tokens_exceeded")));
assert!(v["error"]["message"].as_str().unwrap().contains("maximum context length"));
}
}
/// HTTP error for a failed backend run. A request larger than the whole KV pool can never fit: 422 with the DI capacity marker
/// ("maximum context length"), so clients do not retry it. A transient shortage (concurrent requests hold the KV cache) stays 529.
pub fn backend_error(m: &str) -> ApiError {
if m.contains("cannot decode image") {
ApiError::new(422, "cannot decode image as a base64 data URI")
} else if m.contains("KV cache capacity exceeded") {
ApiError::context(format!("This model's maximum context length is exceeded: the request does not fit in the server's KV cache ({m})"))
} else if m.contains("KV pool exhausted") {
// transient overload (concurrent requests hold the KV cache): TypeSafe's 529, clients retry after a short delay
ApiError::new(529, format!("overloaded, retry after a short delay ({m})"))
} else {
ApiError::new(500, format!("inference failed: {m}"))
}
}
|