Skip to main content

cookie_agent_models/catalog/
records.rs

1use std::collections::BTreeMap;
2
3use cookie_agent_identity::{CanonicalModelId, CatalogRevision, ProviderId, ProviderModelId};
4use jiff::Timestamp;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7/// Exact selected source for one dynamic catalog snapshot.
8#[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/// Durable catalog availability projected to runtime/TUI state.
17#[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/// Non-expiring age warning for a selected validated body.
26#[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/// Complete durable catalog state; age never makes a cache unusable.
35#[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/// Bounded, body-free error metadata safe for persistence and display.
44#[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/// Structural quarantine reason for independently recoverable catalog records.
53#[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/// One sorted safe quarantine diagnostic.
91#[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/// A provider row that remains visible even when its local record quarantines.
101#[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/// Strict provider-scoped catalog metadata.
110#[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/// A provider-model row with executable metadata isolated from canonical metadata.
124#[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/// Provider-scoped model metadata compiled by the family registry.
133#[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    // Catalog prices are runtime metadata; frozen manifest inputs prohibit JSON floats.
155    #[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/// Safe metadata-only canonical record. It cannot select runtime behavior.
397#[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/// Exact-key, metadata-only provenance link.
410#[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/// Immutable dynamic catalog snapshot.
418#[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}