1use std::collections::BTreeMap;
2
3use cookie_agent_identity::{CanonicalModelId, CatalogRevision, ProviderId, ProviderModelId};
4use jiff::Timestamp;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CatalogSource {
11 Network,
12 Cache,
13 Bootstrap,
14}
15
16#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum CatalogAvailability {
20 Ready,
21 Stale,
22 Bootstrap,
23}
24
25#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum CatalogAgeState {
29 Current,
30 OlderThanSevenDays,
31 OlderThanThirtyDays,
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(deny_unknown_fields)]
37pub struct CatalogRuntimeState {
38 pub availability: CatalogAvailability,
39 pub age: CatalogAgeState,
40 pub last_error: Option<CatalogSafeErrorMeta>,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45#[serde(deny_unknown_fields)]
46pub struct CatalogSafeErrorMeta {
47 pub code: String,
48 pub safe_message: String,
49 pub occurred_at: Timestamp,
50}
51
52#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum CatalogQuarantineReason {
56 InvalidCatalogProviderRecord,
57 InvalidCatalogModelRecord,
58 InvalidCanonicalModelRecord,
59 DuplicateProviderId,
60 AmbiguousProviderId,
61 DuplicateProviderModelId,
62 AmbiguousProviderModelId,
63 DuplicateCanonicalModelId,
64 AmbiguousCanonicalModelId,
65 ProviderIdentityMismatch,
66 ProviderModelIdentityMismatch,
67 CanonicalModelIdentityMismatch,
68}
69
70impl CatalogQuarantineReason {
71 #[must_use]
72 pub const fn code(&self) -> &'static str {
73 match self {
74 Self::InvalidCatalogProviderRecord => "invalid_catalog_provider_record",
75 Self::InvalidCatalogModelRecord => "invalid_catalog_model_record",
76 Self::InvalidCanonicalModelRecord => "invalid_canonical_model_record",
77 Self::DuplicateProviderId => "duplicate_provider_id",
78 Self::AmbiguousProviderId => "ambiguous_provider_id",
79 Self::DuplicateProviderModelId => "duplicate_provider_model_id",
80 Self::AmbiguousProviderModelId => "ambiguous_provider_model_id",
81 Self::DuplicateCanonicalModelId => "duplicate_canonical_model_id",
82 Self::AmbiguousCanonicalModelId => "ambiguous_canonical_model_id",
83 Self::ProviderIdentityMismatch => "provider_identity_mismatch",
84 Self::ProviderModelIdentityMismatch => "provider_model_identity_mismatch",
85 Self::CanonicalModelIdentityMismatch => "canonical_model_identity_mismatch",
86 }
87 }
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct CatalogQuarantineEntry {
94 pub provider_id: Option<String>,
95 pub model_id: Option<String>,
96 pub canonical_model_id: Option<String>,
97 pub reason: CatalogQuarantineReason,
98}
99
100#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
102#[serde(deny_unknown_fields)]
103pub struct CatalogProviderEntry {
104 pub id: ProviderId,
105 pub record: Option<CatalogProviderRecord>,
106 pub quarantine: Option<CatalogQuarantineReason>,
107}
108
109#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
111#[serde(deny_unknown_fields)]
112pub struct CatalogProviderRecord {
113 pub id: ProviderId,
114 pub name: String,
115 pub environment: Vec<String>,
116 pub npm: String,
117 pub api: Option<String>,
118 pub shape: Option<String>,
119 pub documentation_url: String,
120 pub models: BTreeMap<ProviderModelId, CatalogModelEntry>,
121}
122
123#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
125#[serde(deny_unknown_fields)]
126pub struct CatalogModelEntry {
127 pub id: ProviderModelId,
128 pub record: Option<CatalogModelRecord>,
129 pub quarantine: Option<CatalogQuarantineReason>,
130}
131
132#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
134#[serde(deny_unknown_fields)]
135pub struct CatalogModelRecord {
136 pub id: ProviderModelId,
137 pub name: String,
138 pub description: String,
139 pub family: Option<String>,
140 pub attachment: bool,
141 pub reasoning: bool,
142 pub tool_call: bool,
143 pub structured_output: Option<bool>,
144 pub temperature: Option<bool>,
145 pub open_weights: bool,
146 pub status: CatalogModelStatus,
147 pub release_date: String,
148 pub last_updated: String,
149 pub modalities: CatalogModalities,
150 pub limits: CatalogLimits,
151 pub shape: Option<String>,
152 pub provider: Option<CatalogModelProviderMetadata>,
153 pub reasoning_options: Vec<CatalogReasoningOption>,
154 #[serde(default, skip_serializing)]
156 pub cost: Option<CatalogModelCost>,
157 pub interleaved: Option<CatalogInterleaved>,
158 pub canonical_provenance: Option<CanonicalModelProvenance>,
159}
160
161#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
162pub struct PicoUsdPerMillion(u128);
163
164impl PicoUsdPerMillion {
165 const SCALE: i32 = 12;
166
167 #[must_use]
168 pub const fn new(value: u128) -> Self {
169 Self(value)
170 }
171
172 #[must_use]
173 pub const fn value(self) -> u128 {
174 self.0
175 }
176
177 fn decimal_string(self) -> String {
178 let whole = self.0 / 1_000_000_000_000;
179 let fraction = self.0 % 1_000_000_000_000;
180 if fraction == 0 {
181 whole.to_string()
182 } else {
183 let fraction = format!("{fraction:012}");
184 format!("{whole}.{}", fraction.trim_end_matches('0'))
185 }
186 }
187
188 pub fn from_decimal_str(value: &str) -> Option<Self> {
189 let value = value.trim();
190 if value.is_empty() || value.starts_with('-') {
191 return None;
192 }
193 let value = value.strip_prefix('+').unwrap_or(value);
194 let (coefficient, exponent) =
195 value
196 .split_once(['e', 'E'])
197 .map_or((value, 0), |(coefficient, exponent)| {
198 exponent
199 .parse::<i32>()
200 .map(|exponent| (coefficient, exponent))
201 .unwrap_or(("", 0))
202 });
203 if coefficient.is_empty() {
204 return None;
205 }
206 let (whole, fraction) = coefficient.split_once('.').unwrap_or((coefficient, ""));
207 if (whole.is_empty() && fraction.is_empty())
208 || !whole.bytes().all(|byte| byte.is_ascii_digit())
209 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
210 {
211 return None;
212 }
213 let digits = format!("{whole}{fraction}");
214 let mut significant = digits.trim_start_matches('0');
215 if significant.is_empty() {
216 return Some(Self(0));
217 }
218 let fraction_digits = i32::try_from(fraction.len()).ok()?;
219 let mut shift = Self::SCALE
220 .checked_add(exponent)?
221 .checked_sub(fraction_digits)?;
222 if shift < 0 {
223 let excess = usize::try_from(shift.unsigned_abs()).ok()?;
224 let retained = significant.len().checked_sub(excess)?;
225 if retained == 0 || !significant[retained..].bytes().all(|byte| byte == b'0') {
226 return None;
227 }
228 significant = &significant[..retained];
229 shift = 0;
230 }
231 let coefficient = significant.parse::<u128>().ok()?;
232 let scaled = coefficient.checked_mul(checked_power_of_ten(u32::try_from(shift).ok()?)?)?;
233 (scaled > 0).then_some(Self(scaled))
234 }
235}
236
237fn checked_power_of_ten(exponent: u32) -> Option<u128> {
238 (0..exponent).try_fold(1_u128, |value, _| value.checked_mul(10))
239}
240
241impl<'de> Deserialize<'de> for PicoUsdPerMillion {
242 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
243 where
244 D: Deserializer<'de>,
245 {
246 let value = String::deserialize(deserializer)?;
247 Self::from_decimal_str(&value)
248 .ok_or_else(|| serde::de::Error::custom("invalid pico-USD pricing rate"))
249 }
250}
251
252impl Serialize for PicoUsdPerMillion {
253 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
254 where
255 S: Serializer,
256 {
257 serializer.serialize_str(&self.decimal_string())
258 }
259}
260
261#[cfg(test)]
262mod pricing_tests {
263 use super::PicoUsdPerMillion;
264
265 #[test]
266 fn exact_decimal_rates_accept_supported_forms_and_reject_loss() {
267 assert_eq!(
268 PicoUsdPerMillion::from_decimal_str("0.125")
269 .unwrap()
270 .value(),
271 125_000_000_000
272 );
273 assert_eq!(
274 PicoUsdPerMillion::from_decimal_str("1.250000000000")
275 .unwrap()
276 .value(),
277 1_250_000_000_000
278 );
279 assert!(PicoUsdPerMillion::from_decimal_str("0.0000000000015").is_none());
280 assert!(
281 PicoUsdPerMillion::from_decimal_str("340282366920938463463374607431768211455")
282 .is_none()
283 );
284 }
285}
286
287#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
288#[serde(deny_unknown_fields)]
289pub struct CatalogModelCost {
290 pub input: PicoUsdPerMillion,
291 pub output: PicoUsdPerMillion,
292 pub reasoning: Option<PicoUsdPerMillion>,
293 pub cache_read: Option<PicoUsdPerMillion>,
294 pub cache_write: Option<PicoUsdPerMillion>,
295 #[serde(default)]
296 pub context_over_200k: Option<CatalogModelCostRates>,
297 #[serde(default)]
298 pub tiers: Vec<CatalogModelCostTier>,
299}
300
301#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
302#[serde(deny_unknown_fields)]
303pub struct CatalogModelCostRates {
304 pub input: PicoUsdPerMillion,
305 pub output: PicoUsdPerMillion,
306 pub reasoning: Option<PicoUsdPerMillion>,
307 pub cache_read: Option<PicoUsdPerMillion>,
308 pub cache_write: Option<PicoUsdPerMillion>,
309}
310
311#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
312#[serde(deny_unknown_fields)]
313pub struct CatalogModelCostTier {
314 pub context_tokens: u64,
315 pub rates: CatalogModelCostRates,
316}
317
318impl CatalogModelCost {
319 #[must_use]
320 pub fn rates_for_input(&self, input_tokens: u64) -> CatalogModelCostRates {
321 let mut selected = CatalogModelCostRates {
322 input: self.input,
323 output: self.output,
324 reasoning: self.reasoning,
325 cache_read: self.cache_read,
326 cache_write: self.cache_write,
327 };
328 let mut selected_threshold = 0;
329 if input_tokens >= 200_000
330 && let Some(rates) = self.context_over_200k
331 {
332 selected = rates;
333 selected_threshold = 200_000;
334 }
335 for tier in &self.tiers {
336 if input_tokens < tier.context_tokens {
337 break;
338 }
339 if tier.context_tokens >= selected_threshold {
340 selected = tier.rates;
341 selected_threshold = tier.context_tokens;
342 }
343 }
344 selected
345 }
346}
347
348#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
349#[serde(rename_all = "snake_case")]
350pub enum CatalogInterleaved {
351 Default,
352 ReasoningContent,
353 Reasoning,
354}
355
356#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
357#[serde(rename_all = "snake_case")]
358pub enum CatalogModelStatus {
359 Stable,
360 Alpha,
361 Beta,
362 Deprecated,
363}
364
365#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
366#[serde(deny_unknown_fields)]
367pub struct CatalogModalities {
368 pub input: Vec<String>,
369 pub output: Vec<String>,
370}
371
372#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
373#[serde(deny_unknown_fields)]
374pub struct CatalogLimits {
375 pub context: u64,
376 pub input: Option<u64>,
377 pub output: u64,
378}
379
380#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
381#[serde(deny_unknown_fields)]
382pub struct CatalogModelProviderMetadata {
383 pub npm: Option<String>,
384 pub api: Option<String>,
385 pub shape: Option<String>,
386}
387
388#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
389#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
390pub enum CatalogReasoningOption {
391 Effort { values: Vec<Option<String>> },
392 Toggle,
393 BudgetTokens { min: Option<i64>, max: Option<i64> },
394}
395
396#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
398#[serde(deny_unknown_fields)]
399pub struct CanonicalModelRecord {
400 pub id: CanonicalModelId,
401 pub name: String,
402 pub description: String,
403 pub family: Option<String>,
404 pub release_date: String,
405 pub last_updated: String,
406 pub metadata_digest: String,
407}
408
409#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
411#[serde(deny_unknown_fields)]
412pub struct CanonicalModelProvenance {
413 pub id: CanonicalModelId,
414 pub metadata_digest: String,
415}
416
417#[derive(Clone, Debug)]
419pub struct CatalogSnapshot {
420 pub revision: CatalogRevision,
421 pub source: CatalogSource,
422 pub state: CatalogRuntimeState,
423 pub validated_at: Timestamp,
424 pub last_checked_at: Timestamp,
425 pub etag: Option<String>,
426 pub providers: BTreeMap<ProviderId, CatalogProviderEntry>,
427 pub canonical_models: BTreeMap<CanonicalModelId, CanonicalModelRecord>,
428 pub quarantine: Vec<CatalogQuarantineEntry>,
429}
430
431impl CatalogSnapshot {
432 #[must_use]
433 pub fn provider(&self, id: &ProviderId) -> Option<&CatalogProviderEntry> {
434 self.providers.get(id)
435 }
436
437 #[must_use]
438 pub fn model(
439 &self,
440 provider: &ProviderId,
441 model: &ProviderModelId,
442 ) -> Option<&CatalogModelEntry> {
443 self.providers
444 .get(provider)?
445 .record
446 .as_ref()?
447 .models
448 .get(model)
449 }
450}