systemprompt_api/routes/gateway/
models.rs1use 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#[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
98pub fn surfaces_from_header(headers: &HeaderMap) -> Result<Vec<ApiSurface>, (StatusCode, String)> {
99 let Some(raw) = headers
100 .get(INFERENCE_PROTOCOL)
101 .and_then(|v| v.to_str().ok())
102 else {
103 return Ok(Vec::new());
104 };
105 let mut surfaces = Vec::new();
106 for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
107 let surface = ApiSurface::from_tag(tag)
108 .filter(|s| *s != ApiSurface::Backend)
109 .ok_or_else(|| {
110 (
111 StatusCode::BAD_REQUEST,
112 format!("unknown {INFERENCE_PROTOCOL} value: {tag}"),
113 )
114 })?;
115 surfaces.push(surface);
116 }
117 Ok(surfaces)
118}
119
120pub fn model_entries(registry: &ProviderRegistry, surfaces: &[ApiSurface]) -> Vec<ModelEntry> {
121 let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
122 for id in registry.advertised_model_ids(surfaces) {
123 by_id.insert(
124 id.clone(),
125 ModelEntry {
126 kind: "model",
127 display_name: humanize_model_id(&id),
128 id,
129 created_at: "1970-01-01T00:00:00Z".to_owned(),
130 },
131 );
132 }
133 by_id.into_values().collect()
134}
135
136pub fn humanize_model_id(id: &str) -> String {
137 id.split('-')
138 .map(|part| {
139 let mut chars = part.chars();
140 chars.next().map_or_else(String::new, |c| {
141 c.to_ascii_uppercase().to_string() + chars.as_str()
142 })
143 })
144 .collect::<Vec<_>>()
145 .join(" ")
146}