Skip to main content

awaken_runtime_contract/registry_spec/
model_spec.rs

1//! Serializable model offering: addressing, intrinsic capabilities, pricing.
2//!
3//! Carved out of `registry_spec/mod.rs` so the file stays under the
4//! repository's per-file line cap. Public types are re-exported from
5//! `registry_spec` so import paths remain unchanged.
6
7use serde::{Deserialize, Serialize};
8
9/// Input/output modality supported by a model.
10///
11/// Closed set covering the modalities present in major provider APIs
12/// (Anthropic, OpenAI, Google Gemini, Vertex) as of the 2026-Q1
13/// reference window: text, images, audio, video, and PDF documents.
14/// Adding a variant is a breaking change for exhaustive `match` consumers;
15/// removing one is a breaking serde change.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum Modality {
19    Text,
20    Image,
21    Audio,
22    Video,
23    Pdf,
24}
25
26/// Set of modalities a model accepts on input and produces on output.
27#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct Modalities {
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub input: Vec<Modality>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub output: Vec<Modality>,
34}
35
36impl Modalities {
37    /// True when both `input` and `output` lists are empty. Used by serde's
38    /// `skip_serializing_if` so a defaulted `Modalities` is elided rather
39    /// than emitted as `{"input":[],"output":[]}` — keeping minimal
40    /// `ModelSpec` JSON free of empty containers.
41    pub(crate) fn is_empty(&self) -> bool {
42        self.input.is_empty() && self.output.is_empty()
43    }
44}
45
46/// Serializable model offering: addressing (id, provider, upstream model),
47/// intrinsic capabilities (context window, max output tokens, modalities,
48/// knowledge cutoff), and per-million-token pricing.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
50#[serde(deny_unknown_fields)]
51pub struct ModelSpec {
52    /// Stable id used by `AgentSpec.model_id`. Unique within a registry.
53    pub id: String,
54    /// Provider this offering routes through.
55    pub provider_id: String,
56    /// Model name sent to the upstream API.
57    pub upstream_model: String,
58
59    /// Maximum context window in tokens, when published by the provider.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub context_window: Option<u32>,
62    /// Hard ceiling on a single response's output tokens, when published.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub max_output_tokens: Option<u32>,
65    /// Input/output modalities supported by the model.
66    ///
67    /// **Semantics:** an empty `Modalities` (or `default()`) means the model's
68    /// modality set is unspecified, so runtime stays permissive. Explicit
69    /// empty arrays carry the same meaning as omission. When `input` is
70    /// non-empty, runtime rejects requests containing unsupported input
71    /// modalities before calling the provider. To advertise a text-only model,
72    /// set `input: vec![Modality::Text]` explicitly.
73    #[serde(default, skip_serializing_if = "Modalities::is_empty")]
74    pub modalities: Modalities,
75    /// ISO date string (e.g. "2026-01") for the model's training cutoff.
76    ///
77    /// Deserialization rejects any value that is not a well-formed `YYYY-MM` or
78    /// `YYYY-MM-DD` date. This field is runtime-trusted — it is injected
79    /// verbatim into the agent's system context — so an unvalidated string from
80    /// config, a tenant, or an external registry would be a prompt-injection
81    /// surface. Validating at the deserialization boundary closes it for every
82    /// source.
83    #[serde(
84        default,
85        deserialize_with = "deserialize_knowledge_cutoff",
86        skip_serializing_if = "Option::is_none"
87    )]
88    pub knowledge_cutoff: Option<String>,
89
90    /// Optional input-token price in USD per million tokens. When paired
91    /// with `output_token_price_per_million_usd`, eval runs populate
92    /// `ReplayReport.cost_usd` so cost surfaces in regression diffs.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub input_token_price_per_million_usd: Option<f64>,
95    /// Optional output-token price in USD per million tokens.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub output_token_price_per_million_usd: Option<f64>,
98}
99
100impl ModelSpec {
101    /// Convenience constructor for tests and bootstrap code. Capability
102    /// and pricing fields default to `None` / empty.
103    pub fn new(
104        id: impl Into<String>,
105        provider_id: impl Into<String>,
106        upstream_model: impl Into<String>,
107    ) -> Self {
108        Self {
109            id: id.into(),
110            provider_id: provider_id.into(),
111            upstream_model: upstream_model.into(),
112            context_window: None,
113            max_output_tokens: None,
114            modalities: Modalities::default(),
115            knowledge_cutoff: None,
116            input_token_price_per_million_usd: None,
117            output_token_price_per_million_usd: None,
118        }
119    }
120
121    /// USD cost from per-million pricing. Returns `None` unless **both**
122    /// input and output prices are set — partial pricing would silently
123    /// under-report cost.
124    pub fn compute_cost_usd(&self, input_tokens: u32, output_tokens: u32) -> Option<f64> {
125        let ip = self.input_token_price_per_million_usd?;
126        let op = self.output_token_price_per_million_usd?;
127        Some(
128            f64::from(input_tokens) * ip / 1_000_000.0
129                + f64::from(output_tokens) * op / 1_000_000.0,
130        )
131    }
132}
133
134/// Validate an ISO knowledge-cutoff date (`YYYY-MM` or `YYYY-MM-DD`) and return
135/// its trimmed canonical form, or `None` when the value is malformed.
136///
137/// Shared between `ModelSpec` deserialization (which rejects malformed explicit
138/// values) and runtime provider-capability discovery (which drops malformed
139/// discovered values). Pure and side-effect free so callers choose how to
140/// react to `None`.
141#[must_use]
142pub fn normalize_knowledge_cutoff(value: &str) -> Option<String> {
143    let value = value.trim();
144    let bytes = value.as_bytes();
145    let valid_shape = match bytes.len() {
146        7 => {
147            bytes[4] == b'-'
148                && bytes[..4].iter().all(u8::is_ascii_digit)
149                && bytes[5..].iter().all(u8::is_ascii_digit)
150        }
151        10 => {
152            bytes[4] == b'-'
153                && bytes[7] == b'-'
154                && bytes[..4].iter().all(u8::is_ascii_digit)
155                && bytes[5..7].iter().all(u8::is_ascii_digit)
156                && bytes[8..].iter().all(u8::is_ascii_digit)
157        }
158        _ => false,
159    };
160    if !valid_shape {
161        return None;
162    }
163    let month = value[5..7].parse::<u32>().ok()?;
164    if !(1..=12).contains(&month) {
165        return None;
166    }
167    if bytes.len() == 10 {
168        let year = value[..4].parse::<i32>().ok()?;
169        let day = value[8..10].parse::<u32>().ok()?;
170        if day < 1 || day > days_in_month(year, month) {
171            return None;
172        }
173    }
174    Some(value.to_owned())
175}
176
177fn days_in_month(year: i32, month: u32) -> u32 {
178    match month {
179        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
180        4 | 6 | 9 | 11 => 30,
181        2 if is_leap_year(year) => 29,
182        2 => 28,
183        _ => 0,
184    }
185}
186
187fn is_leap_year(year: i32) -> bool {
188    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
189}
190
191/// Reject explicit `knowledge_cutoff` values that are not well-formed ISO
192/// dates, canonicalizing accepted values to their trimmed form.
193fn deserialize_knowledge_cutoff<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
194where
195    D: serde::Deserializer<'de>,
196{
197    let raw = Option::<String>::deserialize(deserializer)?;
198    match raw {
199        None => Ok(None),
200        Some(value) => normalize_knowledge_cutoff(&value).map(Some).ok_or_else(|| {
201            serde::de::Error::custom(format!(
202                "knowledge_cutoff must be an ISO date of the form YYYY-MM or YYYY-MM-DD, got {value:?}"
203            ))
204        }),
205    }
206}