Skip to main content

systemprompt_models/services/providers/
rate_card.rs

1//! The Vertex AI rate card: what a Vertex model costs, and whether we serve it.
2//!
3//! Vertex's publisher listing is Google's global Model Garden catalog. It says
4//! nothing about modality — a chat model, an embedding model and a text-to-
5//! speech model are the same JSON shape — and nothing about entitlement, which
6//! is only proven by calling. So boot-time discovery cannot decide what to
7//! publish on its own.
8//!
9//! This card is that decision, and it is one file rather than two because the
10//! two questions have the same answer: the gateway refuses to dispatch to a
11//! model it cannot price, so "priced here" and "allowed here" are necessarily
12//! the same set. A listed model with no entry is reported as unpriced and left
13//! unpublished; an entry Vertex stops listing is reported rather than deleted.
14//!
15//! Every entry also carries its lifecycle as Google's documentation states it
16//! — launch stage, release date, retirement date, and the page it was read
17//! from — because "currently supported" is a documentation fact, not a
18//! listing fact: Vertex keeps listing a model right up to the day it is
19//! switched off. [`VertexRateCardEntry::is_supported`] is the rule, and it is
20//! evaluated against a date so that it can be tested and so that boot never
21//! calls a model to find out whether it still exists.
22//!
23//! `RETIREMENT_NOTICE_DAYS` is how close to retirement a model may be and
24//! still be published: a model retiring inside the window is withheld from
25//! discovery so a developer who picks it today is not cut off mid-project,
26//! while an explicit catalog declaration is the operator's call and is kept
27//! with a warning.
28//!
29//! Copyright (c) systemprompt.io — Business Source License 1.1.
30//! See <https://systemprompt.io> for licensing details.
31
32use chrono::{Days, NaiveDate};
33use serde::{Deserialize, Serialize};
34use systemprompt_identifiers::{ModelId, ProviderId};
35
36use super::{ProviderModel, ProviderRegistryError, ProviderRegistryResult};
37use crate::services::ai::{ModelCapabilities, ModelLimits, ModelPricing};
38
39const VERTEX_RATE_CARD_YAML: &str = include_str!("vertex_rate_card.yaml");
40
41pub const RETIREMENT_NOTICE_DAYS: u64 = 30;
42
43/// The launch stage as Google's model page states it.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum DocumentedLaunchStage {
47    Ga,
48    Preview,
49}
50
51/// One model on the Vertex rate card, every field read from the official
52/// documentation page named in `docs`.
53///
54/// `upstream` is the listing name (`{publisher}/{model}`); `upstream_model` is
55/// what the wire sends — bare on the gemini wire, publisher-qualified on the
56/// `MaaS` openai-chat surface. `launch_stage` and `released` are the stage and
57/// release date the page states (`released` is absent when only a deprecation
58/// notice still names the model); `allow_preview` publishes a model the page
59/// marks preview or experimental. `retires_on` is the announced retirement
60/// date and `price_until` the last day the recorded price applies when an
61/// introductory price has an announced end — the scheduler warns ahead of both.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct VertexRateCardEntry {
65    pub upstream: String,
66
67    pub provider: ProviderId,
68
69    pub id: ModelId,
70
71    pub upstream_model: String,
72
73    #[serde(default)]
74    pub allow_preview: bool,
75
76    pub launch_stage: DocumentedLaunchStage,
77
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub released: Option<NaiveDate>,
80
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub retires_on: Option<NaiveDate>,
83
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub price_until: Option<NaiveDate>,
86
87    pub docs: String,
88
89    #[serde(default)]
90    pub pricing: ModelPricing,
91
92    #[serde(default)]
93    pub capabilities: ModelCapabilities,
94
95    #[serde(default)]
96    pub limits: ModelLimits,
97
98    #[serde(default)]
99    pub aliases: Vec<ModelId>,
100}
101
102impl VertexRateCardEntry {
103    #[must_use]
104    pub fn publisher(&self) -> &str {
105        self.upstream.split('/').next().unwrap_or(&self.upstream)
106    }
107
108    #[must_use]
109    pub fn is_supported(&self, today: NaiveDate) -> bool {
110        let stage_ok = self.launch_stage == DocumentedLaunchStage::Ga || self.allow_preview;
111        stage_ok && !self.is_retiring(today)
112    }
113
114    #[must_use]
115    pub fn is_retiring(&self, today: NaiveDate) -> bool {
116        let horizon = today
117            .checked_add_days(Days::new(RETIREMENT_NOTICE_DAYS))
118            .unwrap_or(today);
119        self.retires_on.is_some_and(|retires| retires <= horizon)
120    }
121
122    #[must_use]
123    pub fn to_provider_model(&self) -> ProviderModel {
124        ProviderModel {
125            id: self.id.clone(),
126            aliases: self.aliases.clone(),
127            upstream_model: Some(self.upstream_model.clone()),
128            pricing: self.pricing,
129            capabilities: self.capabilities,
130            limits: self.limits,
131            governance: None,
132        }
133    }
134
135    fn validate(&self) -> ProviderRegistryResult<()> {
136        let id = self.id.as_str();
137        if !self.docs.starts_with("https://") {
138            return Err(ProviderRegistryError::InvalidVertexRateCard(format!(
139                "{id}: `docs` must be the official documentation URL"
140            )));
141        }
142        let Some(released) = self.released else {
143            return Ok(());
144        };
145        if self.retires_on.is_some_and(|retires| retires <= released) {
146            return Err(ProviderRegistryError::InvalidVertexRateCard(format!(
147                "{id}: `retires_on` is not after `released`"
148            )));
149        }
150        if self.price_until.is_some_and(|until| until <= released) {
151            return Err(ProviderRegistryError::InvalidVertexRateCard(format!(
152                "{id}: `price_until` is not after `released`"
153            )));
154        }
155        Ok(())
156    }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct VertexRateCard {
162    pub entries: Vec<VertexRateCardEntry>,
163}
164
165impl VertexRateCard {
166    pub fn embedded() -> ProviderRegistryResult<Self> {
167        let card: Self = serde_yaml::from_str(VERTEX_RATE_CARD_YAML)
168            .map_err(|e| ProviderRegistryError::InvalidVertexRateCard(e.to_string()))?;
169        card.validate()?;
170        Ok(card)
171    }
172
173    pub fn validate(&self) -> ProviderRegistryResult<()> {
174        self.entries
175            .iter()
176            .try_for_each(VertexRateCardEntry::validate)
177    }
178
179    #[must_use]
180    pub fn lookup(&self, upstream: &str) -> Option<&VertexRateCardEntry> {
181        self.entries.iter().find(|e| e.upstream == upstream)
182    }
183
184    #[must_use]
185    pub fn lookup_id(&self, id: &str) -> Option<&VertexRateCardEntry> {
186        self.entries.iter().find(|e| e.id.as_str() == id)
187    }
188
189    pub fn entries_for<'a>(
190        &'a self,
191        provider: &'a str,
192    ) -> impl Iterator<Item = &'a VertexRateCardEntry> {
193        self.entries
194            .iter()
195            .filter(move |e| e.provider.as_str() == provider)
196    }
197
198    #[must_use]
199    pub fn publishers_for(&self, provider: &str) -> Vec<String> {
200        let mut publishers: Vec<String> = Vec::new();
201        for entry in self.entries_for(provider) {
202            let publisher = entry.publisher();
203            if !publishers.iter().any(|p| p == publisher) {
204                publishers.push(publisher.to_owned());
205            }
206        }
207        publishers
208    }
209}