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_manifest::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    // JSON: Vertex AI `supportedActions` — undocumented action map, only probed for keys.
63    pub supported_actions: Option<serde_json::Value>,
64
65    #[serde(default)]
66    pub open_source_category: Option<String>,
67}
68
69impl PublisherModel {
70    #[must_use]
71    pub fn publisher(&self) -> &str {
72        let mut parts = self.name.split('/');
73        if parts.next() == Some("publishers") {
74            parts.next().unwrap_or_default()
75        } else {
76            ""
77        }
78    }
79
80    #[must_use]
81    pub fn model_name(&self) -> &str {
82        self.name.rsplit('/').next().unwrap_or(&self.name)
83    }
84
85    #[must_use]
86    pub fn upstream(&self) -> String {
87        format!("{}/{}", self.publisher(), self.model_name())
88    }
89
90    #[must_use]
91    pub fn is_generally_available(&self) -> bool {
92        self.launch_stage == GA
93    }
94
95    #[must_use]
96    pub fn discovered(&self) -> DiscoveredModel {
97        DiscoveredModel {
98            upstream: self.upstream(),
99            launch_stage: if self.is_generally_available() {
100                LaunchStage::GenerallyAvailable
101            } else {
102                LaunchStage::Preview
103            },
104            serverless: is_serverless(self),
105        }
106    }
107
108    fn is_deployable_checkpoint(&self) -> bool {
109        self.supported_actions
110            .as_ref()
111            .is_some_and(|actions| actions.get("deploy").is_some())
112    }
113
114    fn declares_actions(&self) -> bool {
115        match self.supported_actions.as_ref() {
116            None | Some(serde_json::Value::Null) => false,
117            Some(serde_json::Value::Object(actions)) => !actions.is_empty(),
118            Some(_) => true,
119        }
120    }
121}
122
123#[must_use]
124pub fn is_serverless(model: &PublisherModel) -> bool {
125    if model.is_deployable_checkpoint() {
126        return false;
127    }
128    if model.publisher() == GOOGLE_PUBLISHER {
129        return true;
130    }
131    model.model_name().ends_with(MAAS_SUFFIX)
132        && model.open_source_category.as_deref() == Some(THIRD_PARTY_OSS)
133        && !model.declares_actions()
134}
135
136/// What discovery decided about one listing entry.
137///
138/// Not serverless (needs a deploy, or not a `MaaS` partner model); callable
139/// but unpriced by the rate card, so never served; priced but not GA with a
140/// card entry that does not opt into previews; or priced and publishable.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum Classification {
143    NotServerless,
144    Unpriced,
145    PreviewWithheld,
146    Publish,
147}
148
149#[must_use]
150pub fn classify<'a>(
151    model: &PublisherModel,
152    card: &'a VertexRateCard,
153    provider: &str,
154) -> (Classification, Option<&'a VertexRateCardEntry>) {
155    classify_discovered(&model.discovered(), card, provider)
156}
157
158#[must_use]
159pub fn classify_discovered<'a>(
160    model: &DiscoveredModel,
161    card: &'a VertexRateCard,
162    provider: &str,
163) -> (Classification, Option<&'a VertexRateCardEntry>) {
164    if !model.serverless {
165        return (Classification::NotServerless, None);
166    }
167    let Some(entry) = card
168        .lookup(&model.upstream)
169        .filter(|e| e.provider.as_str() == provider)
170    else {
171        return (Classification::Unpriced, None);
172    };
173    if !model.launch_stage.is_generally_available() && !entry.allow_preview {
174        return (Classification::PreviewWithheld, Some(entry));
175    }
176    (Classification::Publish, Some(entry))
177}