Skip to main content

systemprompt_api/routes/gateway/
models.rs

1//! `/v1/models` catalog endpoint: the whole advertised model catalog.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use axum::Json;
7use axum::http::{HeaderMap, StatusCode};
8use serde::Serialize;
9use std::collections::BTreeMap;
10use systemprompt_identifiers::headers::INFERENCE_PROTOCOL;
11use systemprompt_loader::ServicesBootstrap;
12use systemprompt_models::bridge::profile::is_model_servable;
13use systemprompt_models::services::{ApiSurface, GatewayConfig, ProviderRegistry};
14
15#[derive(Debug, Serialize)]
16pub struct RootResponse {
17    pub service: &'static str,
18    pub version: &'static str,
19    pub endpoints: Vec<&'static str>,
20}
21
22pub async fn root() -> Json<RootResponse> {
23    Json(RootResponse {
24        service: "systemprompt-gateway",
25        version: env!("CARGO_PKG_VERSION"),
26        endpoints: vec![
27            "/v1/models",
28            "/v1/messages",
29            "/v1/responses",
30            "/v1/chat/completions",
31        ],
32    })
33}
34
35#[derive(Debug, Serialize)]
36pub struct ModelEntry {
37    #[serde(rename = "type")]
38    pub kind: &'static str,
39    pub id: String,
40    pub display_name: String,
41    pub created_at: String,
42    // Why: Claude Code's gateway model discovery sizes its context window from
43    // these, as it does against Anthropic's Models API; without them every
44    // model is treated as 200k even when the catalog serves 1M.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub max_input_tokens: Option<u32>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub max_tokens: Option<u32>,
49}
50
51#[derive(Debug, Serialize)]
52pub struct ModelsResponse {
53    pub data: Vec<ModelEntry>,
54    pub has_more: bool,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub first_id: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub last_id: Option<String>,
59}
60
61/// Query parameters accepted by [`list`].
62///
63/// Gateway model discovery requests `/v1/models?limit=1000`. An unparseable or
64/// absent value returns the whole catalog rather than failing: discovery has a
65/// three-second budget and treats any non-success as "no models", so a strict
66/// parse would cost the developer their picker entries over a cosmetic input.
67#[derive(Debug, Default, Clone, serde::Deserialize)]
68pub struct ListQuery {
69    pub limit: Option<usize>,
70    pub format: Option<String>,
71}
72
73#[derive(Debug, Serialize)]
74pub struct OpenAiModelEntry {
75    pub id: String,
76    pub object: &'static str,
77    pub created: u64,
78    pub owned_by: &'static str,
79}
80
81#[derive(Debug, Serialize)]
82pub struct OpenAiModelsResponse {
83    pub object: &'static str,
84    pub data: Vec<OpenAiModelEntry>,
85}
86
87pub async fn list(
88    headers: HeaderMap,
89    axum::extract::Query(query): axum::extract::Query<ListQuery>,
90) -> Result<axum::response::Response, (StatusCode, String)> {
91    let services = ServicesBootstrap::get().map_err(|e| {
92        (
93            StatusCode::SERVICE_UNAVAILABLE,
94            format!("Services config not ready: {e}"),
95        )
96    })?;
97
98    let gateway = services
99        .gateway_config()
100        .filter(|g| g.enabled)
101        .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
102    let secrets = systemprompt_config::SecretsBootstrap::get().map_err(|e| {
103        (
104            StatusCode::SERVICE_UNAVAILABLE,
105            format!("Secrets not ready: {e}"),
106        )
107    })?;
108
109    // Why: the `x-inference-protocol` header is advisory, not a filter. The
110    // gateway transcodes every inbound wire to every provider wire, so a client
111    // that speaks one family is still served every advertised model; narrowing
112    // the catalog by the header would hide models the caller can actually use.
113    // The header is still parsed and validated so an unknown tag (or `backend`)
114    // is rejected rather than silently accepted.
115    let _surfaces = surfaces_from_header(&headers)?;
116    let mut entries = model_entries(&services.providers, &[], Some(gateway), |name| {
117        secrets.get(name).is_some_and(|k| !k.is_empty())
118    });
119    let total = entries.len();
120    let has_more = match query.limit {
121        Some(limit) if limit < total => {
122            entries.truncate(limit);
123            true
124        },
125        _ => false,
126    };
127
128    if query.format.as_deref() == Some("openai") {
129        let data = entries
130            .into_iter()
131            .map(|e| OpenAiModelEntry {
132                id: e.id,
133                object: "model",
134                created: 0,
135                owned_by: "systemprompt",
136            })
137            .collect();
138        return Ok(axum::response::IntoResponse::into_response(Json(
139            OpenAiModelsResponse {
140                object: "list",
141                data,
142            },
143        )));
144    }
145
146    let first_id = entries.first().map(|e| e.id.clone());
147    let last_id = entries.last().map(|e| e.id.clone());
148
149    Ok(axum::response::IntoResponse::into_response(Json(
150        ModelsResponse {
151            data: entries,
152            has_more,
153            first_id,
154            last_id,
155        },
156    )))
157}
158
159pub fn surfaces_from_header(headers: &HeaderMap) -> Result<Vec<ApiSurface>, (StatusCode, String)> {
160    let Some(raw) = headers
161        .get(INFERENCE_PROTOCOL)
162        .and_then(|v| v.to_str().ok())
163    else {
164        return Ok(Vec::new());
165    };
166    let mut surfaces = Vec::new();
167    for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
168        let surface = ApiSurface::from_tag(tag)
169            .filter(|s| *s != ApiSurface::Backend)
170            .ok_or_else(|| {
171                (
172                    StatusCode::BAD_REQUEST,
173                    format!("unknown {INFERENCE_PROTOCOL} value: {tag}"),
174                )
175            })?;
176        surfaces.push(surface);
177    }
178    Ok(surfaces)
179}
180
181// Why: a model whose serving provider has no credential answers every request
182// with an error, so listing it only puts a dead entry in the client's picker.
183pub fn model_entries(
184    registry: &ProviderRegistry,
185    surfaces: &[ApiSurface],
186    gateway: Option<&GatewayConfig>,
187    secret_present: impl Fn(&str) -> bool,
188) -> Vec<ModelEntry> {
189    let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
190    for id in registry
191        .advertised_model_ids(surfaces)
192        .into_iter()
193        .filter(|id| is_model_servable(registry, gateway, id, &secret_present))
194    {
195        let limits = registry
196            .providers
197            .iter()
198            .find_map(|p| p.find_model(&id))
199            .map(|m| &m.limits);
200        by_id.insert(
201            id.clone(),
202            ModelEntry {
203                kind: "model",
204                display_name: humanize_model_id(&id),
205                created_at: model_created_at(&id),
206                max_input_tokens: limits.map(|l| l.context_window).filter(|n| *n > 0),
207                max_tokens: limits.map(|l| l.max_output_tokens).filter(|n| *n > 0),
208                id,
209            },
210        );
211    }
212    by_id.into_values().collect()
213}
214
215// Why: consecutive numeric segments are a version (`4-5` is 4.5) and a
216// trailing eight-digit segment is the release date, which `model_created_at`
217// carries; the picker shows `Claude Haiku 4.5`, never `20251001`.
218pub fn humanize_model_id(id: &str) -> String {
219    let parts: Vec<&str> = id.split('-').filter(|p| !p.is_empty()).collect();
220    let parts = match parts.split_last() {
221        Some((last, rest)) if is_release_date(last) => rest,
222        _ => parts.as_slice(),
223    };
224    let mut out = String::with_capacity(id.len());
225    let mut prev_numeric = false;
226    for part in parts {
227        let numeric = part.bytes().all(|b| b.is_ascii_digit());
228        if !out.is_empty() {
229            out.push(if numeric && prev_numeric { '.' } else { ' ' });
230        }
231        if numeric {
232            out.push_str(part);
233        } else {
234            let mut chars = part.chars();
235            if let Some(c) = chars.next() {
236                out.extend(c.to_uppercase());
237                out.push_str(chars.as_str());
238            }
239        }
240        prev_numeric = numeric;
241    }
242    out
243}
244
245fn is_release_date(part: &str) -> bool {
246    part.len() == 8 && part.bytes().all(|b| b.is_ascii_digit())
247}
248
249// Why: the Unix epoch is the documented "unknown" value for this field.
250pub fn model_created_at(id: &str) -> String {
251    const EPOCH: &str = "1970-01-01T00:00:00Z";
252    let Some(date) = id.rsplit('-').next().filter(|p| is_release_date(p)) else {
253        return EPOCH.to_owned();
254    };
255    chrono::NaiveDate::parse_from_str(date, "%Y%m%d").map_or_else(
256        |_| EPOCH.to_owned(),
257        |d| format!("{}T00:00:00Z", d.format("%Y-%m-%d")),
258    )
259}