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![
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}
42
43#[derive(Debug, Serialize)]
44pub struct ModelsResponse {
45    pub data: Vec<ModelEntry>,
46    pub has_more: bool,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub first_id: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub last_id: Option<String>,
51}
52
53/// Query parameters accepted by [`list`].
54///
55/// Gateway model discovery requests `/v1/models?limit=1000`. An unparseable or
56/// absent value returns the whole catalog rather than failing: discovery has a
57/// three-second budget and treats any non-success as "no models", so a strict
58/// parse would cost the developer their picker entries over a cosmetic input.
59#[derive(Debug, Default, Clone, serde::Deserialize)]
60pub struct ListQuery {
61    pub limit: Option<usize>,
62    pub format: Option<String>,
63}
64
65#[derive(Debug, Serialize)]
66pub struct OpenAiModelEntry {
67    pub id: String,
68    pub object: &'static str,
69    pub created: u64,
70    pub owned_by: &'static str,
71}
72
73#[derive(Debug, Serialize)]
74pub struct OpenAiModelsResponse {
75    pub object: &'static str,
76    pub data: Vec<OpenAiModelEntry>,
77}
78
79pub async fn list(
80    headers: HeaderMap,
81    axum::extract::Query(query): axum::extract::Query<ListQuery>,
82) -> Result<axum::response::Response, (StatusCode, String)> {
83    let profile = ProfileBootstrap::get().map_err(|e| {
84        (
85            StatusCode::SERVICE_UNAVAILABLE,
86            format!("Profile not ready: {e}"),
87        )
88    })?;
89
90    profile
91        .gateway
92        .as_ref()
93        .and_then(systemprompt_models::profile::GatewayState::resolved)
94        .filter(|g| g.enabled)
95        .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
96
97    let surfaces = surfaces_from_header(&headers)?;
98    let mut entries = model_entries(&profile.providers, &surfaces);
99    let total = entries.len();
100    let has_more = match query.limit {
101        Some(limit) if limit < total => {
102            entries.truncate(limit);
103            true
104        },
105        _ => false,
106    };
107
108    if query.format.as_deref() == Some("openai") {
109        let data = entries
110            .into_iter()
111            .map(|e| OpenAiModelEntry {
112                id: e.id,
113                object: "model",
114                created: 0,
115                owned_by: "systemprompt",
116            })
117            .collect();
118        return Ok(axum::response::IntoResponse::into_response(Json(
119            OpenAiModelsResponse {
120                object: "list",
121                data,
122            },
123        )));
124    }
125
126    let first_id = entries.first().map(|e| e.id.clone());
127    let last_id = entries.last().map(|e| e.id.clone());
128
129    Ok(axum::response::IntoResponse::into_response(Json(
130        ModelsResponse {
131            data: entries,
132            has_more,
133            first_id,
134            last_id,
135        },
136    )))
137}
138
139pub fn surfaces_from_header(headers: &HeaderMap) -> Result<Vec<ApiSurface>, (StatusCode, String)> {
140    let Some(raw) = headers
141        .get(INFERENCE_PROTOCOL)
142        .and_then(|v| v.to_str().ok())
143    else {
144        return Ok(Vec::new());
145    };
146    let mut surfaces = Vec::new();
147    for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
148        let surface = ApiSurface::from_tag(tag)
149            .filter(|s| *s != ApiSurface::Backend)
150            .ok_or_else(|| {
151                (
152                    StatusCode::BAD_REQUEST,
153                    format!("unknown {INFERENCE_PROTOCOL} value: {tag}"),
154                )
155            })?;
156        surfaces.push(surface);
157    }
158    Ok(surfaces)
159}
160
161pub fn model_entries(registry: &ProviderRegistry, surfaces: &[ApiSurface]) -> Vec<ModelEntry> {
162    let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
163    for id in registry.advertised_model_ids(surfaces) {
164        by_id.insert(
165            id.clone(),
166            ModelEntry {
167                kind: "model",
168                display_name: humanize_model_id(&id),
169                id,
170                created_at: "1970-01-01T00:00:00Z".to_owned(),
171            },
172        );
173    }
174    by_id.into_values().collect()
175}
176
177pub fn humanize_model_id(id: &str) -> String {
178    id.split('-')
179        .map(|part| {
180            let mut chars = part.chars();
181            chars.next().map_or_else(String::new, |c| {
182                c.to_ascii_uppercase().to_string() + chars.as_str()
183            })
184        })
185        .collect::<Vec<_>>()
186        .join(" ")
187}