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