systemprompt_api/routes/gateway/
models.rs1use 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 #[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#[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 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
195pub 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
229pub 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}