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_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}
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 services = ServicesBootstrap::get().map_err(|e| {
84        (
85            StatusCode::SERVICE_UNAVAILABLE,
86            format!("Services config not ready: {e}"),
87        )
88    })?;
89
90    services
91        .gateway_config()
92        .filter(|g| g.enabled)
93        .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
94
95    let surfaces = surfaces_from_header(&headers)?;
96    let mut entries = model_entries(&services.providers, &surfaces);
97    let total = entries.len();
98    let has_more = match query.limit {
99        Some(limit) if limit < total => {
100            entries.truncate(limit);
101            true
102        },
103        _ => false,
104    };
105
106    if query.format.as_deref() == Some("openai") {
107        let data = entries
108            .into_iter()
109            .map(|e| OpenAiModelEntry {
110                id: e.id,
111                object: "model",
112                created: 0,
113                owned_by: "systemprompt",
114            })
115            .collect();
116        return Ok(axum::response::IntoResponse::into_response(Json(
117            OpenAiModelsResponse {
118                object: "list",
119                data,
120            },
121        )));
122    }
123
124    let first_id = entries.first().map(|e| e.id.clone());
125    let last_id = entries.last().map(|e| e.id.clone());
126
127    Ok(axum::response::IntoResponse::into_response(Json(
128        ModelsResponse {
129            data: entries,
130            has_more,
131            first_id,
132            last_id,
133        },
134    )))
135}
136
137pub fn surfaces_from_header(headers: &HeaderMap) -> Result<Vec<ApiSurface>, (StatusCode, String)> {
138    let Some(raw) = headers
139        .get(INFERENCE_PROTOCOL)
140        .and_then(|v| v.to_str().ok())
141    else {
142        return Ok(Vec::new());
143    };
144    let mut surfaces = Vec::new();
145    for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
146        let surface = ApiSurface::from_tag(tag)
147            .filter(|s| *s != ApiSurface::Backend)
148            .ok_or_else(|| {
149                (
150                    StatusCode::BAD_REQUEST,
151                    format!("unknown {INFERENCE_PROTOCOL} value: {tag}"),
152                )
153            })?;
154        surfaces.push(surface);
155    }
156    Ok(surfaces)
157}
158
159pub fn model_entries(registry: &ProviderRegistry, surfaces: &[ApiSurface]) -> Vec<ModelEntry> {
160    let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
161    for id in registry.advertised_model_ids(surfaces) {
162        by_id.insert(
163            id.clone(),
164            ModelEntry {
165                kind: "model",
166                display_name: humanize_model_id(&id),
167                id,
168                created_at: "1970-01-01T00:00:00Z".to_owned(),
169            },
170        );
171    }
172    by_id.into_values().collect()
173}
174
175pub fn humanize_model_id(id: &str) -> String {
176    id.split('-')
177        .map(|part| {
178            let mut chars = part.chars();
179            chars.next().map_or_else(String::new, |c| {
180                c.to_ascii_uppercase().to_string() + chars.as_str()
181            })
182        })
183        .collect::<Vec<_>>()
184        .join(" ")
185}