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