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}