Skip to main content

oino_ai/
primitives.rs

1//! Scalar types: timestamps, thinking levels, stop reasons, usage.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8use crate::json::js_f64;
9
10/// Unix epoch milliseconds (`number` timestamps on messages).
11pub type TimestampMs = i64;
12
13/// Reasoning effort requested from a model. Wire values are lowercase
14/// (`"xhigh"`, not `"x_high"`).
15#[derive(
16    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
17)]
18#[serde(rename_all = "lowercase")]
19pub enum ThinkingLevel {
20    /// No reasoning.
21    #[default]
22    Off,
23    /// Minimal reasoning.
24    Minimal,
25    /// Low reasoning.
26    Low,
27    /// Medium reasoning.
28    Medium,
29    /// High reasoning.
30    High,
31    /// Extra-high reasoning (only when the model maps it).
32    Xhigh,
33    /// Maximum reasoning (only when the model maps it).
34    Max,
35}
36
37impl ThinkingLevel {
38    /// Every level in declaration order (`EXTENDED_THINKING_LEVELS`).
39    pub const ALL: [Self; 7] = [
40        Self::Off,
41        Self::Minimal,
42        Self::Low,
43        Self::Medium,
44        Self::High,
45        Self::Xhigh,
46        Self::Max,
47    ];
48
49    /// Wire string (`"off"`, …, `"xhigh"`, `"max"`).
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Off => "off",
54            Self::Minimal => "minimal",
55            Self::Low => "low",
56            Self::Medium => "medium",
57            Self::High => "high",
58            Self::Xhigh => "xhigh",
59            Self::Max => "max",
60        }
61    }
62
63    /// The requested level if available, else the nearest *higher*
64    /// available level, else the nearest lower one, else the first available (or `Off`).
65    #[must_use]
66    pub fn clamp(self, available: &[Self]) -> Self {
67        if available.contains(&self) {
68            return self;
69        }
70        let requested = self as usize;
71        Self::ALL[requested..]
72            .iter()
73            .chain(Self::ALL[..requested].iter().rev())
74            .copied()
75            .find(|level| available.contains(level))
76            .unwrap_or_else(|| available.first().copied().unwrap_or(Self::Off))
77    }
78}
79
80impl fmt::Display for ThinkingLevel {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86/// Error returned when a string is not a thinking level.
87#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
88#[error("unknown thinking level: {0}")]
89pub struct UnknownThinkingLevel(pub String);
90
91impl FromStr for ThinkingLevel {
92    type Err = UnknownThinkingLevel;
93
94    fn from_str(s: &str) -> Result<Self, Self::Err> {
95        Self::ALL
96            .into_iter()
97            .find(|level| level.as_str() == s)
98            .ok_or_else(|| UnknownThinkingLevel(s.to_owned()))
99    }
100}
101
102/// Why an assistant message stopped.
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub enum StopReason {
106    /// Streaming partial (`message_start` assistants).
107    Pending,
108    /// Normal end of turn.
109    Stop,
110    /// Output token limit reached.
111    Length,
112    /// The model requested tool calls.
113    ToolUse,
114    /// Provider or transport error.
115    Error,
116    /// Aborted by the user.
117    Aborted,
118    /// Deferred (batch) response; see `AssistantMessage::deferred`.
119    Deferred,
120}
121
122/// Input modality a model accepts.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum InputModality {
126    /// Text input.
127    Text,
128    /// Image input.
129    Image,
130}
131
132/// Prompt-cache retention requested from providers.
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum CacheRetention {
136    /// No caching.
137    None,
138    /// Short (default) retention.
139    Short,
140    /// Long retention (e.g. Anthropic 1h).
141    Long,
142}
143
144/// Cost breakdown in USD.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct UsageCost {
148    /// Input token cost.
149    #[serde(with = "js_f64")]
150    pub input: f64,
151    /// Output token cost.
152    #[serde(with = "js_f64")]
153    pub output: f64,
154    /// Cache read cost.
155    #[serde(with = "js_f64")]
156    pub cache_read: f64,
157    /// Cache write cost.
158    #[serde(with = "js_f64")]
159    pub cache_write: f64,
160    /// Sum of all buckets.
161    #[serde(with = "js_f64")]
162    pub total: f64,
163}
164
165/// Token usage and cost.
166#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct Usage {
169    /// Uncached input tokens.
170    pub input: u64,
171    /// Output tokens.
172    pub output: u64,
173    /// Cache read tokens.
174    pub cache_read: u64,
175    /// Cache write tokens (all retentions).
176    pub cache_write: u64,
177    /// Portion of `cache_write` written with 1h retention.
178    #[serde(
179        rename = "cacheWrite1h",
180        default,
181        skip_serializing_if = "Option::is_none"
182    )]
183    pub cache_write_1h: Option<u64>,
184    /// Reasoning tokens; `None` means the provider did not report them.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub reasoning: Option<u64>,
187    /// Total tokens.
188    pub total_tokens: u64,
189    /// Cost breakdown (required).
190    #[serde(default)]
191    pub cost: UsageCost,
192}
193
194impl Usage {
195    /// Adds `other` into `self` (token counts and cost buckets).
196    pub fn accumulate(&mut self, other: &Self) {
197        self.input += other.input;
198        self.output += other.output;
199        self.cache_read += other.cache_read;
200        self.cache_write += other.cache_write;
201        self.cache_write_1h = add_opt(self.cache_write_1h, other.cache_write_1h);
202        self.reasoning = add_opt(self.reasoning, other.reasoning);
203        self.total_tokens += other.total_tokens;
204        self.cost.input += other.cost.input;
205        self.cost.output += other.cost.output;
206        self.cost.cache_read += other.cost.cache_read;
207        self.cost.cache_write += other.cost.cache_write;
208        self.cost.total += other.cost.total;
209    }
210}
211
212fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
213    match (a, b) {
214        (None, None) => None,
215        (a, b) => Some(a.unwrap_or(0) + b.unwrap_or(0)),
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde_json::json;
223
224    #[test]
225    fn thinking_level_wire_values() {
226        assert_eq!(
227            serde_json::to_value(ThinkingLevel::Xhigh).ok(),
228            Some(json!("xhigh"))
229        );
230        assert_eq!("max".parse::<ThinkingLevel>(), Ok(ThinkingLevel::Max));
231        assert!("x_high".parse::<ThinkingLevel>().is_err());
232    }
233
234    #[test]
235    fn clamp_prefers_higher_then_lower() {
236        use ThinkingLevel::{High, Low, Max, Medium, Minimal, Off, Xhigh};
237        let available = [Off, Minimal, Low, Medium, High];
238        assert_eq!(Xhigh.clamp(&available), High);
239        assert_eq!(Max.clamp(&available), High);
240        assert_eq!(Low.clamp(&available), Low);
241        assert_eq!(Minimal.clamp(&[Off, Medium]), Medium);
242        assert_eq!(High.clamp(&[Off]), Off);
243        assert_eq!(High.clamp(&[]), Off);
244    }
245
246    #[test]
247    fn usage_round_trips_pi_shape() {
248        let raw = json!({"input":0,"output":0,"cacheRead":50000,"cacheWrite":0,"totalTokens":50000,
249            "cost":{"input":0,"output":0,"cacheRead":0.015,"cacheWrite":0,"total":0.015}});
250        let usage: Usage = serde_json::from_value(raw.clone()).unwrap_or_default();
251        assert_eq!(usage.cache_read, 50_000);
252        assert_eq!(serde_json::to_value(usage).ok(), Some(raw));
253        let text = serde_json::to_string(&usage).unwrap_or_default();
254        assert!(text.contains("\"input\":0,"), "{text}");
255    }
256}