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