span-02 / engine /crates /server /src /wire.rs
lihe07's picture
span-02: single-source release folder (engine final path, soft blend)
d2d4a42 verified
Raw History Blame Contribute Delete
6.4 kB
//! Wire format of POST /v1/systemone: the Decision Index / TypeSafe System One request and response (checked by decision_index.engines.base.validate).
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde::ser::{Serialize as Ser, SerializeMap, Serializer};
#[derive(Debug, Deserialize)]
pub struct Request {
#[serde(default)]
pub model: Option<String>,
pub state: Value,
/// Decision Index kit image input: up to 4 data URIs or bare base64 strings, shown to the model in order before the text
#[serde(default)]
pub images: Option<Value>,
/// TypeSafe assets: {placeholder: {"type": "image", "format": "png"|"jpg"|"jpeg", "data_base64": ...} | {"part": multipart part name}};
/// every placeholder must occur in `state` or a question's instructions / criteria (string values, never object keys)
#[serde(default)]
pub assets: Option<Value>,
/// insertion order matters (probabilities are reported per criteria key; serde_json preserve_order keeps it)
pub questions: serde_json::Map<String, Value>,
/// reasoning controls: {"mode": "auto"|"on"|"off", "budget_ms": ms, "trace": bool}
#[serde(default)]
pub reasoning: Option<ReasoningReq>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ReasoningReq {
/// "auto" (default: the server's gate decides per question) | "on" (every question thinks) | "off" (no question thinks)
#[serde(default)]
pub mode: Option<String>,
/// wall-clock thinking budget in ms, [0, 600000]; 0 = none (thoughts run to the end-of-thought token or the cap).
/// Absent: the server's budget, except mode "on" which then has no wall-clock limit
#[serde(default)]
pub budget_ms: Option<f64>,
/// return each thought's decoded text in `answers[q].reasoning.trace`
#[serde(default)]
pub trace: Option<bool>,
}
/// JSON object that keeps insertion order (Python dict semantics: answers in question order, probabilities in criteria order)
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OrderedMap<V>(pub Vec<(String, V)>);
impl<V> OrderedMap<V> {
pub fn new() -> Self {
OrderedMap(vec![])
}
pub fn insert(&mut self, k: String, v: V) {
self.0.push((k, v));
}
pub fn get(&self, k: &str) -> Option<&V> {
self.0.iter().find(|(a, _)| a == k).map(|(_, v)| v)
}
}
impl<V: Ser> Ser for OrderedMap<V> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(Some(self.0.len()))?;
for (k, v) in &self.0 {
m.serialize_entry(k, v)?;
}
m.end()
}
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AnswerOut {
Choice { choice: String, confidence: f32, probabilities: OrderedMap<f32> },
Noul { noul: f32 },
/// TypeSafe `score`: ordinal levels 0..n-1 (criteria array order); legend echoes each level as sent
Score { score: f32, legend: OrderedMap<Value>, probabilities: OrderedMap<f32>, confidence: f32 },
/// free-form number (extension): `number` = parsed value (null if `text` is not a number), `text` = the generated answer,
/// `confidence` = exp(mean log-probability of the answer tokens)
Number { number: Option<f64>, text: String, confidence: f32 },
}
/// one answer: the typed fields, plus `reasoning` only when the question thought
#[derive(Debug, Serialize, PartialEq)]
pub struct Answer {
#[serde(flatten)]
pub answer: AnswerOut,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningOut>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct ReasoningOut {
/// thought tokens generated for this question
pub tokens: usize,
/// decoded thought text; only when the request asked for traces
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<String>,
}
/// request totals
#[derive(Debug, Serialize, Default)]
pub struct Usage {
/// prompt tokens summed over the questions (each question's prompt incl. the shared state, and every image's soft tokens + 2 delimiters)
pub input_tokens: usize,
/// thought tokens generated, summed over the questions
pub reasoning_tokens: usize,
/// answer tokens generated: the tokens of `number` answers (choice / noul / score answers are read from one forward's logits and generate none)
pub output_tokens: usize,
/// server-side time from request arrival to the response
pub latency_ms: f64,
}
#[derive(Debug, Serialize)]
pub struct Response {
pub model: String,
pub answers: OrderedMap<Answer>,
pub usage: Usage,
/// total image soft tokens of the request (read by the DI vision runner into environment.json effective_image_budget.max_image_tokens); omitted without images
#[serde(skip_serializing_if = "Option::is_none")]
pub image_tokens: Option<usize>,
}
/// error body `{"error": {"type": kind, "message": msg}}` with HTTP status `code`
#[derive(Debug)]
pub struct ApiError {
pub code: u16,
pub kind: &'static str,
pub msg: String,
}
impl ApiError {
/// the default kind of each status: 400 invalid_request, 401 authentication_error, 413 request_too_large, 415 unsupported_encoding,
/// 422 validation_error, 529 overloaded, otherwise server_error
pub fn new(code: u16, msg: impl Into<String>) -> Self {
let kind = match code {
400 => "invalid_request",
401 => "authentication_error",
413 => "request_too_large",
415 => "unsupported_encoding",
422 => "validation_error",
529 => "overloaded",
_ => "server_error",
};
ApiError { code, kind, msg: msg.into() }
}
pub fn kind(mut self, kind: &'static str) -> Self {
self.kind = kind;
self
}
/// prompt (+ images + thinking reserve) over the context limit, or a request larger than the whole KV cache: TypeSafe's 400
/// `max_tokens_exceeded`; the message always contains "maximum context length" (the DI kit's capacity marker -> unsupported)
pub fn context(msg: impl Into<String>) -> Self {
ApiError::new(400, msg).kind("max_tokens_exceeded")
}
/// a rejected asset (TypeSafe: 400)
pub fn asset(msg: impl Into<String>) -> Self {
ApiError::new(400, msg).kind("invalid_asset")
}
}