From 566cc568960d01e6325484699f4ef65013646676 Mon Sep 17 00:00:00 2001 From: Jacob Date: Sat, 24 Oct 2026 02:33:31 -0400 Subject: [PATCH] chore: extraction model api (no proxying implemented yet) --- Cargo.lock | 9 +- Cargo.toml | 1 + src/openai/extraction.rs | 524 +++++++++++++++++++++++++++++++++ src/openai/mod.rs | 1 + src/server/chat/completions.rs | 20 +- src/server/embeddings.rs | 34 ++- src/server/mod.rs | 2 +- src/server/system_one.rs | 9 +- 8 files changed, 571 insertions(+), 29 deletions(-) create mode 100644 src/openai/extraction.rs diff --git a/Cargo.lock b/Cargo.lock index 1792f55..61c08f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,9 +1496,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -1523,9 +1523,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -1633,6 +1633,7 @@ dependencies = [ "headers", "indexmap 2.14.2", "itertools 0.15.0", + "jiff", "mitsein", "rand 0.10.3", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 34afd0d..a1093a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ futures = "0.3.34" headers = "0.4.2" indexmap = "2.14.2" itertools = "0.15.0" +jiff = { version = "0.2.38", features = ["serde"] } mitsein = { version = "0.9.0", features = ["indexmap"] } rand = "0.10.3" reqwest = { version = "0.13.5", features = ["brotli", "cookies", "deflate", "form", "gzip", "hickory-dns", "json", "multipart", "query", "rustls-no-provider", "socks", "stream", "zstd"], default-features = false } diff --git a/src/openai/extraction.rs b/src/openai/extraction.rs new file mode 100644 index 0000000..1fbd91a --- /dev/null +++ b/src/openai/extraction.rs @@ -0,0 +1,524 @@ +//! System One-style models that can extract, compute, estimate, +//! and forecast values from inputs. +//! They are strongly typed and output confidence scores and probabilities against their outputs. +//! +//! Request: [`ExtractionRequest`] · Response: [`ExtractionResponse`] + +use jiff::civil::{Date, DateTime, Time}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The item type required for `list` fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ListItems { + /// List of text items. + Text, + /// List of numbers. + Number, + /// List of ISO dates. + Date, +} + +/// Scale for estimate/forecast grids. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum Scale { + /// Linear scale (default). + #[default] + Linear, + /// Logarithmic scale. + Log, +} + +/// Interval for forecast fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ForecastInterval { + /// One step of the grid. + Step, + /// Per minute. + Minute, + /// Per hour. + Hour, + /// Per day. + Day, + /// Per week. + Week, + /// Per month. + Month, + /// Per quarter. + Quarter, + /// Per year. + Year, +} + +/// A value that may be a number or an ISO date string. Used for the +/// `min`/`max` bounds of estimate/forecast fields (ISO date for date +/// estimates) and for the `step` grid. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum NumberOrDate { + /// A numeric bound or step size. + Number(f64), + /// An ISO date string (for date estimates). + Date(Date), +} + +/// A step size for estimate grids: a positive number (days for date +/// estimates), or the strings `"month"` / `"year"`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum EstimateStep { + /// A positive numeric step (days for date estimates). + Number(f64), + /// Calendar-aware step: `"month"`. + Month, + /// Calendar-aware step: `"year"`. + Year, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DurationUnit { + Seconds, + Minutes, + Hours, + Days, + Weeks, + Months, + Years, +} + +/// A typed field specification +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Field { + /// A string extracted from the input. + #[serde(rename = "text")] + Text { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Probability of null above which the value is null (default 0.5). + /// Must be in `(0, 1]`. + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// A numeric value. + #[serde(rename = "number")] + Number { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Unit label for the number (e.g. `USD`). + #[serde(skip_serializing_if = "Option::is_none")] + unit: Option, + /// Whether the number must be an integer. Numbers and durations only. + #[serde(skip_serializing_if = "Option::is_none")] + integer: Option, + /// Number of decimal places (0–6). Numbers, durations and number + /// lists only. + #[serde(skip_serializing_if = "Option::is_none")] + decimals: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// An ISO date value. + #[serde(rename = "date")] + Date { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// An ISO time value. + #[serde(rename = "time")] + Time { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// An ISO date-time value. + #[serde(rename = "datetime")] + Datetime { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// A duration value. + #[serde(rename = "duration")] + Duration { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Unit label + #[serde(skip_serializing_if = "Option::is_none")] + unit: Option, + /// Whether the duration must be an integer. Numbers and durations only. + #[serde(skip_serializing_if = "Option::is_none")] + integer: Option, + /// Number of decimal places (0–6). Numbers, durations and number + /// lists only. + #[serde(skip_serializing_if = "Option::is_none")] + decimals: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// A list of typed items. + #[serde(rename = "list")] + List { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Required for list fields: the type of each item. + items: ListItems, + /// Number of decimal places (0–6) for number lists. + #[serde(skip_serializing_if = "Option::is_none")] + decimals: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// A probabilistic estimate over a bounded grid. + #[serde(rename = "estimate")] + Estimate { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Required for estimate/forecast: lower bound (a number, or an ISO + /// date for date estimates). + min: NumberOrDate, + /// Required for estimate/forecast: upper bound (a number, or an ISO + /// date for date estimates). + max: NumberOrDate, + /// Estimate grid: a positive number (days for date estimates), or + /// `"month"` / `"year"`. + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + /// Grid scale: `linear` (default) or `log`. + #[serde(skip_serializing_if = "Option::is_none")] + scale: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, + /// A forecast: a sequence of estimates over a horizon. + #[serde(rename = "forecast")] + Forecast { + /// What the field should contain. + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + /// Required for estimate/forecast: lower bound (a number, or an ISO + /// date for date estimates). + min: NumberOrDate, + /// Required for estimate/forecast: upper bound (a number, or an ISO + /// date for date estimates). + max: NumberOrDate, + /// Estimate grid: a positive number (days for date estimates), or + /// `"month"` / `"year"`. + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + /// Grid scale: `linear` (default) or `log`. + #[serde(skip_serializing_if = "Option::is_none")] + scale: Option, + /// Forecast only: number of steps (1–64). + #[serde(skip_serializing_if = "Option::is_none")] + horizon: Option, + /// Forecast only: the interval between steps. + #[serde(skip_serializing_if = "Option::is_none")] + interval: Option, + /// Probability of null above which the value is null (default 0.5). + #[serde(skip_serializing_if = "Option::is_none")] + null_threshold: Option, + }, +} + +/// An image input. Either a data URL string or an explicit base64 object. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Image { + /// A data URL string (must start with `data:`, case-insensitive). + DataUrl( + /// The data URL, e.g. `data:image/png;base64,...`. + String, + ), + /// An explicitly split base64 image. + Base64 { + /// MIME content type of the image (PNG, JPEG or WebP). + content_type: String, + /// Base64-encoded image bytes. + base64: String, + }, +} + +/// Optional request settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct RequestOptions { + /// Add 99 percentiles to estimates (default `false`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_quantiles: Option, + /// Return the text that spans index into (default `false`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub return_state_text: Option, +} + +/// The extraction request body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ExtractionRequest { + /// The model selector + pub model: Model, + /// The text to read and predict from. Must be non-empty unless images + /// are sent. Text answers are spans of this string. + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + /// Required. Map of field name to a typed field spec. At least one + /// field; no upper limit. Fields that do not fit next to the state in + /// one 8,192-token pass are split across parallel passes. Field names + /// must match `^[A-Za-z_][A-Za-z0-9_.\-]{0,63}$`. + pub fields: HashMap, + /// Optional embedded PNG, JPEG or WebP images placed before the state + /// (max 4; 4 MiB each, 8 MiB total decoded; request body max 16 MiB). + /// Remote URLs are not accepted. + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, + /// ISO date that anchors dates written without a year. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_date: Option, + /// Optional request settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +/// The value of an answer. Shape depends on the field type; `null` when the +/// input does not determine it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum List { + // A list of strings + Strings(Vec), + // A list of numbers + Numbers(Vec), + // A list of dates + Dates(Vec), +} + +/// A forecast step: one estimate per step, with median and interval bounds. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ForecastStep { + /// The point (median) value for this step. + pub value: f64, + /// Mean of the distribution for this step. + #[serde(skip_serializing_if = "Option::is_none")] + pub mean: Option, + /// Lower bound of the interval for this step. + #[serde(skip_serializing_if = "Option::is_none")] + pub p10: Option, + /// Upper bound of the interval for this step. + #[serde(skip_serializing_if = "Option::is_none")] + pub p90: Option, +} + +/// One answer per requested field, keyed by field name. Tagged by `type`, +/// mirroring the field spec that produced it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Answer { + /// Answer for a text field: an exact span of the state, with character + /// offsets. + #[serde(rename = "text")] + Text { + /// The extracted value, or null when the input does not determine it. + value: Option, + /// The model's best guess before applying the null threshold. + #[serde(skip_serializing_if = "Option::is_none")] + guess: Option, + /// Probability that the field is null. + #[serde(skip_serializing_if = "Option::is_none")] + p_null: Option, + /// Confidence score in `[0, 1]`. + #[serde(skip_serializing_if = "Option::is_none")] + confidence: Option, + /// Character offsets `[start, end]` of the span into the state. + #[serde(skip_serializing_if = "Option::is_none")] + span: Option<[usize; 2]>, + }, + /// Answer for a number field. + #[serde(rename = "number")] + Number { + /// The extracted value, or null when the input does not determine it. + value: Option, + /// The model's best guess before applying the null threshold. + #[serde(skip_serializing_if = "Option::is_none")] + guess: Option, + /// Probability that the field is null. + #[serde(skip_serializing_if = "Option::is_none")] + p_null: Option, + /// Confidence score in `[0, 1]`. + #[serde(skip_serializing_if = "Option::is_none")] + confidence: Option, + }, + /// Answer for a date field. + #[serde(rename = "date")] + Date { + /// The computed/extracted ISO date, or null. + value: Option, + /// The model's best guess before applying the null threshold. + #[serde(skip_serializing_if = "Option::is_none")] + guess: Option, + /// Probability that the field is null. + #[serde(skip_serializing_if = "Option::is_none")] + p_null: Option, + /// Confidence score in `[0, 1]`. + #[serde(skip_serializing_if = "Option::is_none")] + confidence: Option, + }, + /// Answer for a time field. + #[serde(rename = "time")] + Time { + /// The extracted ISO time, or null. + value: Option