Skip to main content

systemprompt_api/routes/gateway/
models.rs

1//! `/v1/models` catalog endpoint filtered by inference-protocol surface.
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_config::ProfileBootstrap;
11use systemprompt_identifiers::headers::INFERENCE_PROTOCOL;
12use systemprompt_models::profile::{ApiSurface, ProviderRegistry};
13
14#[derive(Debug, Serialize)]
15pub struct RootResponse {
16    pub service: &'static str,
17    pub version: &'static str,
18    pub endpoints: Vec<&'static str>,
19}
20
21pub async fn root() -> Json<RootResponse> {
22    Json(RootResponse {
23        service: "systemprompt-gateway",
24        version: env!("CARGO_PKG_VERSION"),
25        endpoints: vec!["/v1/models", "/v1/messages"],
26    })
27}
28
29#[derive(Debug, Serialize)]
30pub struct ModelEntry {
31    #[serde(rename = "type")]
32    pub kind: &'static str,
33    pub id: String,
34    pub display_name: String,
35    pub created_at: String,
36}
37
38#[derive(Debug, Serialize)]
39pub struct ModelsResponse {
40    pub data: Vec<ModelEntry>,
41    pub has_more: bool,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub first_id: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub last_id: Option<String>,
46}
47
48/// Query parameters accepted by [`list`].
49///
50/// Gateway model discovery requests `/v1/models?limit=1000`. An unparseable or
51/// absent value returns the whole catalog rather than failing: discovery has a
52/// three-second budget and treats any non-success as "no models", so a strict
53/// parse would cost the developer their picker entries over a cosmetic input.
54#[derive(Debug, Default, Clone, Copy, serde::Deserialize)]
55pub struct ListQuery {
56    pub limit: Option<usize>,
57}
58
59pub async fn list(
60    headers: HeaderMap,
61    axum::extract::Query(query): axum::extract::Query<ListQuery>,
62) -> Result<Json<ModelsResponse>, (StatusCode, String)> {
63    let profile = ProfileBootstrap::get().map_err(|e| {
64        (
65            StatusCode::SERVICE_UNAVAILABLE,
66            format!("Profile not ready: {e}"),
67        )
68    })?;
69
70    profile
71        .gateway
72        .as_ref()
73        .and_then(systemprompt_models::profile::GatewayState::resolved)
74        .filter(|g| g.enabled)
75        .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
76
77    let surfaces = surfaces_from_header(&headers)?;
78    let mut entries = model_entries(&profile.providers, &surfaces);
79    let total = entries.len();
80    let has_more = match query.limit {
81        Some(limit) if limit < total => {
82            entries.truncate(limit);
83            true
84        },
85        _ => false,
86    };
87    let first_id = entries.first().map(|e| e.id.clone());
88    let last_id = entries.last().map(|e| e.id.clone());
89
90    Ok(Json(ModelsResponse {
91        data: entries,
92        has_more,
93        first_id,
94        last_id,
95    }))
96}
97
98/// Resolve the `x-inference-protocol` selection header into API surfaces.
99///
100/// An absent or empty header yields the full catalog (empty slice); an
101/// unrecognised tag, or `backend` (never a client surface), is a
102/// misconfiguration and fails with `400` rather than silently widening or
103/// leaking the advertised set.
104pub fn surfaces_from_header(headers: &HeaderMap) -> Result<Vec<ApiSurface>, (StatusCode, String)> {
105    let Some(raw) = headers
106        .get(INFERENCE_PROTOCOL)
107        .and_then(|v| v.to_str().ok())
108    else {
109        return Ok(Vec::new());
110    };
111    let mut surfaces = Vec::new();
112    for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
113        let surface = ApiSurface::from_tag(tag)
114            .filter(|s| *s != ApiSurface::Backend)
115            .ok_or_else(|| {
116                (
117                    StatusCode::BAD_REQUEST,
118                    format!("unknown {INFERENCE_PROTOCOL} value: {tag}"),
119                )
120            })?;
121        surfaces.push(surface);
122    }
123    Ok(surfaces)
124}
125
126pub fn model_entries(registry: &ProviderRegistry, surfaces: &[ApiSurface]) -> Vec<ModelEntry> {
127    let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
128    for id in registry.advertised_model_ids(surfaces) {
129        by_id.insert(
130            id.clone(),
131            ModelEntry {
132                kind: "model",
133                display_name: humanize_model_id(&id),
134                id,
135                created_at: "1970-01-01T00:00:00Z".to_owned(),
136            },
137        );
138    }
139    by_id.into_values().collect()
140}
141
142pub fn humanize_model_id(id: &str) -> String {
143    id.split('-')
144        .map(|part| {
145            let mut chars = part.chars();
146            chars.next().map_or_else(String::new, |c| {
147                c.to_ascii_uppercase().to_string() + chars.as_str()
148            })
149        })
150        .collect::<Vec<_>>()
151        .join(" ")
152}