Skip to main content

systemprompt_loader/vertex_discovery/
classify.rs

1//! Deciding which Vertex listing entries are models we can actually serve.
2//!
3//! A Model Garden listing mixes three populations under one JSON shape:
4//! serverless models Google hosts and bills per token; *deployable
5//! checkpoints*, which are weights plus a serving container and are callable
6//! only after you stand up an endpoint yourself; and, under
7//! `publishers/google`, every non-chat modality Google sells — embeddings,
8//! speech, image, video, robotics. There is no field that separates them by
9//! modality.
10//!
11//! So the rule is in two halves. Shape rules out what cannot be called at all
12//! (a checkpoint with a `deploy` action; a partner entry that is not `MaaS`).
13//! The rate card rules in what we are willing to serve — it is the only place
14//! that knows `gemini-2.5-flash` is chat and `gemini-embedding-001` is not.
15//!
16//! Shape (`is_serverless`): Google's own publisher is served serverlessly
17//! across the board, so shape says nothing there and everything is a
18//! candidate. A partner model qualifies only as `MaaS` — the `-maas` suffix
19//! Vertex gives every serverless partner model, the third-party OSS category,
20//! and no actions of its own. Anything with a `deploy` action is a checkpoint.
21//!
22//! Pricing (`classify_discovered`) knows nothing about who listed the model;
23//! every [`CatalogSource`](super::source::CatalogSource) is judged by exactly
24//! this rule, on the provider-agnostic [`DiscoveredModel`] shape.
25//!
26//! Copyright (c) systemprompt.io — Business Source License 1.1.
27//! See <https://systemprompt.io> for licensing details.
28
29use serde::Deserialize;
30use systemprompt_models::services::{VertexRateCard, VertexRateCardEntry};
31
32use super::source::{DiscoveredModel, LaunchStage};
33
34const MAAS_SUFFIX: &str = "-maas";
35
36const THIRD_PARTY_OSS: &str = "THIRD_PARTY_OWNED_OSS";
37
38const GOOGLE_PUBLISHER: &str = "google";
39
40const GA: &str = "GA";
41
42/// One entry of `publisherModels`, named
43/// `publishers/{publisher}/models/{model}`; the rate card names an upstream as
44/// `{publisher}/{model}`.
45///
46/// Unknown fields are ignored on purpose: the listing carries presentation
47/// data (notebook links, container specs, regional availability) that grows
48/// without notice, and a boot-time reader that fails on a new field would turn
49/// a Google release note into an outage.
50#[derive(Debug, Clone, Default, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct PublisherModel {
53    pub name: String,
54
55    #[serde(default)]
56    pub version_id: String,
57
58    #[serde(default)]
59    pub launch_stage: String,
60
61    #[serde(default)]
62    pub supported_actions: Option<serde_json::Value>,
63
64    #[serde(default)]
65    pub open_source_category: Option<String>,
66}
67
68impl PublisherModel {
69    #[must_use]
70    pub fn publisher(&self) -> &str {
71        let mut parts = self.name.split('/');
72        if parts.next() == Some("publishers") {
73            parts.next().unwrap_or_default()
74        } else {
75            ""
76        }
77    }
78
79    #[must_use]
80    pub fn model_name(&self) -> &str {
81        self.name.rsplit('/').next().unwrap_or(&self.name)
82    }
83
84    #[must_use]
85    pub fn upstream(&self) -> String {
86        format!("{}/{}", self.publisher(), self.model_name())
87    }
88
89    #[must_use]
90    pub fn is_generally_available(&self) -> bool {
91        self.launch_stage == GA
92    }
93
94    #[must_use]
95    pub fn discovered(&self) -> DiscoveredModel {
96        DiscoveredModel {
97            upstream: self.upstream(),
98            launch_stage: if self.is_generally_available() {
99                LaunchStage::GenerallyAvailable
100            } else {
101                LaunchStage::Preview
102            },
103            serverless: is_serverless(self),
104        }
105    }
106
107    fn is_deployable_checkpoint(&self) -> bool {
108        self.supported_actions
109            .as_ref()
110            .is_some_and(|actions| actions.get("deploy").is_some())
111    }
112
113    fn declares_actions(&self) -> bool {
114        match self.supported_actions.as_ref() {
115            None | Some(serde_json::Value::Null) => false,
116            Some(serde_json::Value::Object(actions)) => !actions.is_empty(),
117            Some(_) => true,
118        }
119    }
120}
121
122#[must_use]
123pub fn is_serverless(model: &PublisherModel) -> bool {
124    if model.is_deployable_checkpoint() {
125        return false;
126    }
127    if model.publisher() == GOOGLE_PUBLISHER {
128        return true;
129    }
130    model.model_name().ends_with(MAAS_SUFFIX)
131        && model.open_source_category.as_deref() == Some(THIRD_PARTY_OSS)
132        && !model.declares_actions()
133}
134
135/// What discovery decided about one listing entry.
136///
137/// Not serverless (needs a deploy, or not a `MaaS` partner model); callable
138/// but unpriced by the rate card, so never served; priced but not GA with a
139/// card entry that does not opt into previews; or priced and publishable.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Classification {
142    NotServerless,
143    Unpriced,
144    PreviewWithheld,
145    Publish,
146}
147
148#[must_use]
149pub fn classify<'a>(
150    model: &PublisherModel,
151    card: &'a VertexRateCard,
152    provider: &str,
153) -> (Classification, Option<&'a VertexRateCardEntry>) {
154    classify_discovered(&model.discovered(), card, provider)
155}
156
157#[must_use]
158pub fn classify_discovered<'a>(
159    model: &DiscoveredModel,
160    card: &'a VertexRateCard,
161    provider: &str,
162) -> (Classification, Option<&'a VertexRateCardEntry>) {
163    if !model.serverless {
164        return (Classification::NotServerless, None);
165    }
166    let Some(entry) = card
167        .lookup(&model.upstream)
168        .filter(|e| e.provider.as_str() == provider)
169    else {
170        return (Classification::Unpriced, None);
171    };
172    if !model.launch_stage.is_generally_available() && !entry.allow_preview {
173        return (Classification::PreviewWithheld, Some(entry));
174    }
175    (Classification::Publish, Some(entry))
176}