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::bridge::profile::is_model_servable;
13use systemprompt_models::services::{ApiSurface, GatewayConfig, ProviderRegistry};
14
15#[derive(Debug, Serialize)]
16pub struct RootResponse {
17 pub service: &'static str,
18 pub version: &'static str,
19 pub endpoints: Vec<&'static str>,
20}
21
22pub async fn root() -> Json<RootResponse> {
23 Json(RootResponse {
24 service: "systemprompt-gateway",
25 version: env!("CARGO_PKG_VERSION"),
26 endpoints: vec![
27 "/v1/models",
28 "/v1/messages",
29 "/v1/responses",
30 "/v1/chat/completions",
31 ],
32 })
33}
34
35#[derive(Debug, Serialize)]
36pub struct ModelEntry {
37 #[serde(rename = "type")]
38 pub kind: &'static str,
39 pub id: String,
40 pub display_name: String,
41 pub created_at: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
46 pub max_input_tokens: Option<u32>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub max_tokens: Option<u32>,
49}
50
51#[derive(Debug, Serialize)]
52pub struct ModelsResponse {
53 pub data: Vec<ModelEntry>,
54 pub has_more: bool,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub first_id: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub last_id: Option<String>,
59}
60
61#[derive(Debug, Default, Clone, serde::Deserialize)]
68pub struct ListQuery {
69 pub limit: Option<usize>,
70 pub format: Option<String>,
71}
72
73#[derive(Debug, Serialize)]
74pub struct OpenAiModelEntry {
75 pub id: String,
76 pub object: &'static str,
77 pub created: u64,
78 pub owned_by: &'static str,
79}
80
81#[derive(Debug, Serialize)]
82pub struct OpenAiModelsResponse {
83 pub object: &'static str,
84 pub data: Vec<OpenAiModelEntry>,
85}
86
87pub async fn list(
88 headers: HeaderMap,
89 axum::extract::Query(query): axum::extract::Query<ListQuery>,
90) -> Result<axum::response::Response, (StatusCode, String)> {
91 let services = ServicesBootstrap::get().map_err(|e| {
92 (
93 StatusCode::SERVICE_UNAVAILABLE,
94 format!("Services config not ready: {e}"),
95 )
96 })?;
97
98 let gateway = services
99 .gateway_config()
100 .filter(|g| g.enabled)
101 .ok_or_else(|| (StatusCode::NOT_FOUND, "Gateway not enabled".to_owned()))?;
102 let secrets = systemprompt_config::SecretsBootstrap::get().map_err(|e| {
103 (
104 StatusCode::SERVICE_UNAVAILABLE,
105 format!("Secrets not ready: {e}"),
106 )
107 })?;
108
109 let _surfaces = surfaces_from_header(&headers)?;
116 let mut entries = model_entries(&services.providers, &[], Some(gateway), |name| {
117 secrets.get(name).is_some_and(|k| !k.is_empty())
118 });
119 let total = entries.len();
120 let has_more = match query.limit {
121 Some(limit) if limit < total => {
122 entries.truncate(limit);
123 true
124 },
125 _ => false,
126 };
127
128 if query.format.as_deref() == Some("openai") {
129 let data = entries
130 .into_iter()
131 .map(|e| OpenAiModelEntry {
132 id: e.id,
133 object: "model",
134 created: 0,
135 owned_by: "systemprompt",
136 })
137 .collect();
138 return Ok(axum::response::IntoResponse::into_response(Json(
139 OpenAiModelsResponse {
140 object: "list",
141 data,
142 },
143 )));
144 }
145
146 let first_id = entries.first().map(|e| e.id.clone());
147 let last_id = entries.last().map(|e| e.id.clone());
148
149 Ok(axum::response::IntoResponse::into_response(Json(
150 ModelsResponse {
151 data: entries,
152 has_more,
153 first_id,
154 last_id,
155 },
156 )))
157}
158
159pub fn surfaces_from_header(headers: &HeaderMap) -> Result<Vec<ApiSurface>, (StatusCode, String)> {
160 let Some(raw) = headers
161 .get(INFERENCE_PROTOCOL)
162 .and_then(|v| v.to_str().ok())
163 else {
164 return Ok(Vec::new());
165 };
166 let mut surfaces = Vec::new();
167 for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
168 let surface = ApiSurface::from_tag(tag)
169 .filter(|s| *s != ApiSurface::Backend)
170 .ok_or_else(|| {
171 (
172 StatusCode::BAD_REQUEST,
173 format!("unknown {INFERENCE_PROTOCOL} value: {tag}"),
174 )
175 })?;
176 surfaces.push(surface);
177 }
178 Ok(surfaces)
179}
180
181pub fn model_entries(
184 registry: &ProviderRegistry,
185 surfaces: &[ApiSurface],
186 gateway: Option<&GatewayConfig>,
187 secret_present: impl Fn(&str) -> bool,
188) -> Vec<ModelEntry> {
189 let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
190 for id in registry
191 .advertised_model_ids(surfaces)
192 .into_iter()
193 .filter(|id| is_model_servable(registry, gateway, id, &secret_present))
194 {
195 let limits = registry
196 .providers
197 .iter()
198 .find_map(|p| p.find_model(&id))
199 .map(|m| &m.limits);
200 by_id.insert(
201 id.clone(),
202 ModelEntry {
203 kind: "model",
204 display_name: humanize_model_id(&id),
205 created_at: model_created_at(&id),
206 max_input_tokens: limits.map(|l| l.context_window).filter(|n| *n > 0),
207 max_tokens: limits.map(|l| l.max_output_tokens).filter(|n| *n > 0),
208 id,
209 },
210 );
211 }
212 by_id.into_values().collect()
213}
214
215pub fn humanize_model_id(id: &str) -> String {
219 let parts: Vec<&str> = id.split('-').filter(|p| !p.is_empty()).collect();
220 let parts = match parts.split_last() {
221 Some((last, rest)) if is_release_date(last) => rest,
222 _ => parts.as_slice(),
223 };
224 let mut out = String::with_capacity(id.len());
225 let mut prev_numeric = false;
226 for part in parts {
227 let numeric = part.bytes().all(|b| b.is_ascii_digit());
228 if !out.is_empty() {
229 out.push(if numeric && prev_numeric { '.' } else { ' ' });
230 }
231 if numeric {
232 out.push_str(part);
233 } else {
234 let mut chars = part.chars();
235 if let Some(c) = chars.next() {
236 out.extend(c.to_uppercase());
237 out.push_str(chars.as_str());
238 }
239 }
240 prev_numeric = numeric;
241 }
242 out
243}
244
245fn is_release_date(part: &str) -> bool {
246 part.len() == 8 && part.bytes().all(|b| b.is_ascii_digit())
247}
248
249pub fn model_created_at(id: &str) -> String {
251 const EPOCH: &str = "1970-01-01T00:00:00Z";
252 let Some(date) = id.rsplit('-').next().filter(|p| is_release_date(p)) else {
253 return EPOCH.to_owned();
254 };
255 chrono::NaiveDate::parse_from_str(date, "%Y%m%d").map_or_else(
256 |_| EPOCH.to_owned(),
257 |d| format!("{}T00:00:00Z", d.format("%Y-%m-%d")),
258 )
259}