systemprompt-api 0.63.1

Axum-based HTTP server and API gateway for systemprompt.io AI governance infrastructure. Exposes governed agents, MCP, A2A, and admin endpoints with rate limiting and RBAC.
Documentation
//! `/v1/models` catalog endpoint: the whole advertised model catalog.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use axum::Json;
use axum::http::HeaderMap;
use serde::Serialize;
use std::collections::BTreeMap;
use systemprompt_identifiers::headers::INFERENCE_PROTOCOL;
use systemprompt_loader::ServicesBootstrap;
use systemprompt_manifest::bridge_profile::is_model_servable;
use systemprompt_manifest::services::{GatewayConfig, ProviderRegistry};
use systemprompt_models::api::ApiError;
use systemprompt_models::providers::ApiSurface;

use crate::error::ApiHttpError;

#[derive(Debug, Serialize)]
pub struct RootResponse {
    pub service: &'static str,
    pub version: &'static str,
    pub endpoints: Vec<&'static str>,
}

pub async fn root() -> Json<RootResponse> {
    Json(RootResponse {
        service: "systemprompt-gateway",
        version: env!("CARGO_PKG_VERSION"),
        endpoints: vec![
            "/v1/models",
            "/v1/messages",
            "/v1/responses",
            "/v1/chat/completions",
        ],
    })
}

#[derive(Debug, Serialize)]
pub struct ModelEntry {
    #[serde(rename = "type")]
    pub kind: &'static str,
    pub id: String,
    pub display_name: String,
    pub created_at: String,
    // Why: Claude Code's gateway model discovery sizes its context window from
    // these, as it does against Anthropic's Models API; without them every
    // model is treated as 200k even when the catalog serves 1M.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_input_tokens: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_tokens: Option<u32>,
}

#[derive(Debug, Serialize)]
pub struct ModelsResponse {
    pub data: Vec<ModelEntry>,
    pub has_more: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub first_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_id: Option<String>,
}

/// Query parameters accepted by [`list`].
///
/// Gateway model discovery requests `/v1/models?limit=1000`. An unparseable or
/// absent value returns the whole catalog rather than failing: discovery has a
/// three-second budget and treats any non-success as "no models", so a strict
/// parse would cost the developer their picker entries over a cosmetic input.
#[derive(Debug, Default, Clone, serde::Deserialize)]
pub struct ListQuery {
    pub limit: Option<usize>,
    pub format: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct OpenAiModelEntry {
    pub id: String,
    pub object: &'static str,
    pub created: u64,
    pub owned_by: &'static str,
}

#[derive(Debug, Serialize)]
pub struct OpenAiModelsResponse {
    pub object: &'static str,
    pub data: Vec<OpenAiModelEntry>,
}

pub async fn list(
    headers: HeaderMap,
    axum::extract::Query(query): axum::extract::Query<ListQuery>,
) -> Result<axum::response::Response, ApiHttpError> {
    let services = ServicesBootstrap::get()?;

    let gateway = services
        .gateway_config()
        .filter(|g| g.enabled)
        .ok_or_else(|| ApiHttpError::not_found("Gateway not enabled"))?;
    let secrets = systemprompt_config::SecretsBootstrap::get()?;

    // Why: the `x-inference-protocol` header is advisory, not a filter. The
    // gateway transcodes every inbound wire to every provider wire, so a client
    // that speaks one family is still served every advertised model; narrowing
    // the catalog by the header would hide models the caller can actually use.
    // The header is still parsed and validated so an unknown tag (or `backend`)
    // is rejected rather than silently accepted.
    let _surfaces = surfaces_from_header(&headers)?;
    let mut entries = model_entries(&services.providers, &[], Some(gateway), |name| {
        secrets.get(name).is_some_and(|k| !k.is_empty())
    });
    let total = entries.len();
    let has_more = match query.limit {
        Some(limit) if limit < total => {
            entries.truncate(limit);
            true
        },
        _ => false,
    };

    if query.format.as_deref() == Some("openai") {
        let data = entries
            .into_iter()
            .map(|e| OpenAiModelEntry {
                id: e.id,
                object: "model",
                created: 0,
                owned_by: "systemprompt",
            })
            .collect();
        return Ok(axum::response::IntoResponse::into_response(Json(
            OpenAiModelsResponse {
                object: "list",
                data,
            },
        )));
    }

    let first_id = entries.first().map(|e| e.id.clone());
    let last_id = entries.last().map(|e| e.id.clone());

    Ok(axum::response::IntoResponse::into_response(Json(
        ModelsResponse {
            data: entries,
            has_more,
            first_id,
            last_id,
        },
    )))
}

#[derive(Debug, thiserror::Error)]
#[error("unknown {INFERENCE_PROTOCOL} value: {tag}")]
pub struct UnknownInferenceProtocol {
    pub tag: String,
}

impl From<UnknownInferenceProtocol> for ApiHttpError {
    fn from(err: UnknownInferenceProtocol) -> Self {
        Self::from(ApiError::bad_request(err.to_string()))
    }
}

pub fn surfaces_from_header(
    headers: &HeaderMap,
) -> Result<Vec<ApiSurface>, UnknownInferenceProtocol> {
    let Some(raw) = headers
        .get(INFERENCE_PROTOCOL)
        .and_then(|v| v.to_str().ok())
    else {
        return Ok(Vec::new());
    };
    let mut surfaces = Vec::new();
    for tag in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
        let surface = ApiSurface::from_tag(tag)
            .filter(|s| *s != ApiSurface::Backend)
            .ok_or_else(|| UnknownInferenceProtocol {
                tag: tag.to_owned(),
            })?;
        surfaces.push(surface);
    }
    Ok(surfaces)
}

// Why: a model whose serving provider has no credential answers every request
// with an error, so listing it only puts a dead entry in the client's picker.
pub fn model_entries(
    registry: &ProviderRegistry,
    surfaces: &[ApiSurface],
    gateway: Option<&GatewayConfig>,
    secret_present: impl Fn(&str) -> bool,
) -> Vec<ModelEntry> {
    let mut by_id: BTreeMap<String, ModelEntry> = BTreeMap::new();
    for id in registry
        .advertised_model_ids(surfaces)
        .into_iter()
        .filter(|id| is_model_servable(registry, gateway, id, &secret_present))
    {
        let limits = registry
            .providers
            .iter()
            .find_map(|p| p.find_model(&id))
            .map(|m| &m.limits);
        by_id.insert(
            id.clone(),
            ModelEntry {
                kind: "model",
                display_name: humanize_model_id(&id),
                created_at: model_created_at(&id),
                max_input_tokens: limits.map(|l| l.context_window).filter(|n| *n > 0),
                max_tokens: limits.map(|l| l.max_output_tokens).filter(|n| *n > 0),
                id,
            },
        );
    }
    by_id.into_values().collect()
}

// Why: consecutive numeric segments are a version (`4-5` is 4.5) and a
// trailing eight-digit segment is the release date, which `model_created_at`
// carries; the picker shows `Claude Haiku 4.5`, never `20251001`.
pub fn humanize_model_id(id: &str) -> String {
    let parts: Vec<&str> = id.split('-').filter(|p| !p.is_empty()).collect();
    let parts = match parts.split_last() {
        Some((last, rest)) if is_release_date(last) => rest,
        _ => parts.as_slice(),
    };
    let mut out = String::with_capacity(id.len());
    let mut prev_numeric = false;
    for part in parts {
        let numeric = part.bytes().all(|b| b.is_ascii_digit());
        if !out.is_empty() {
            out.push(if numeric && prev_numeric { '.' } else { ' ' });
        }
        if numeric {
            out.push_str(part);
        } else {
            let mut chars = part.chars();
            if let Some(c) = chars.next() {
                out.extend(c.to_uppercase());
                out.push_str(chars.as_str());
            }
        }
        prev_numeric = numeric;
    }
    out
}

fn is_release_date(part: &str) -> bool {
    part.len() == 8 && part.bytes().all(|b| b.is_ascii_digit())
}

// Why: the Unix epoch is the documented "unknown" value for this field.
pub fn model_created_at(id: &str) -> String {
    const EPOCH: &str = "1970-01-01T00:00:00Z";
    let Some(date) = id.rsplit('-').next().filter(|p| is_release_date(p)) else {
        return EPOCH.to_owned();
    };
    chrono::NaiveDate::parse_from_str(date, "%Y%m%d").map_or_else(
        |_| EPOCH.to_owned(),
        |d| format!("{}T00:00:00Z", d.format("%Y-%m-%d")),
    )
}