#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplainResponse {
pub request_id: String,
pub model: String,
pub prediction: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
pub explanation: ShapExplanation,
pub summary: String,
pub latency_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditResponse {
pub record: AuditRecord,
}
#[derive(Debug, Clone)]
pub struct RouterConfig {
pub openai_api: bool,
pub cors: bool,
pub metrics: bool,
}
impl Default for RouterConfig {
fn default() -> Self {
Self {
openai_api: true,
cors: true,
metrics: true,
}
}
}
type Route = (
&'static str,
&'static str,
axum::routing::MethodRouter<AppState>,
);
fn native_routes() -> Vec<Route> {
vec![
("GET", "/health", get(health_handler)),
("GET", "/health/live", get(health_live_handler)),
("GET", "/health/ready", get(health_ready_handler)),
("GET", "/ready", get(health_ready_handler)),
("GET", "/models", get(models_handler)),
("POST", "/tokenize", post(tokenize_handler)),
("POST", "/generate", post(generate_handler)),
("POST", "/batch/tokenize", post(batch_tokenize_handler)),
("POST", "/batch/generate", post(batch_generate_handler)),
("POST", "/stream/generate", post(stream_generate_handler)),
("POST", "/realize/generate", post(stream_generate_handler)),
("POST", "/realize/batch", post(batch_generate_handler)),
("POST", "/realize/embed", post(realize_embed_handler)),
("GET", "/realize/model", get(realize_model_handler)),
("POST", "/realize/reload", post(realize_reload_handler)),
]
}
fn metrics_routes() -> Vec<Route> {
vec![
("GET", "/metrics", get(metrics_handler)),
("GET", "/metrics/dispatch", get(dispatch_metrics_handler)),
(
"POST",
"/metrics/dispatch/reset",
post(dispatch_reset_handler),
),
]
}
fn openai_routes() -> Vec<Route> {
vec![
("GET", "/v1/models", get(openai_models_handler)),
("POST", "/v1/completions", post(openai_completions_handler)),
(
"POST",
"/v1/chat/completions",
post(openai_chat_completions_handler),
),
(
"POST",
"/v1/chat/completions/stream",
post(openai_chat_completions_stream_handler),
),
("POST", "/v1/embeddings", post(openai_embeddings_handler)),
("POST", "/v1/predict", post(apr_predict_handler)),
("POST", "/v1/explain", post(apr_explain_handler)),
("GET", "/v1/audit/:request_id", get(apr_audit_handler)),
("POST", "/v1/gpu/warmup", post(gpu_warmup_handler)),
("GET", "/v1/gpu/status", get(gpu_status_handler)),
(
"POST",
"/v1/batch/completions",
post(gpu_batch_completions_handler),
),
("GET", "/v1/metrics", get(server_metrics_handler)),
("POST", "/api/chat", post(ollama_chat_handler)),
("POST", "/api/generate", post(ollama_generate_handler)),
("GET", "/api/tags", get(ollama_tags_handler)),
("POST", "/api/show", post(ollama_show_handler)),
("GET", "/api/version", get(ollama_version_handler)),
("POST", "/api/embeddings", post(ollama_embeddings_handler)),
]
}
#[cfg(feature = "cuda")]
fn cuda_routes() -> Vec<Route> {
vec![
("POST", "/v1/logprobs", post(logprobs_handler)),
("POST", "/v1/perplexity", post(perplexity_handler)),
]
}
fn route_table(config: &RouterConfig) -> Vec<Route> {
let mut table = native_routes();
if config.metrics {
table.extend(metrics_routes());
}
if config.openai_api {
table.extend(openai_routes());
}
#[cfg(feature = "cuda")]
table.extend(cuda_routes());
table
}
pub fn advertised_routes(config: &RouterConfig) -> Vec<String> {
route_index_of(&route_table(config))
}
fn route_index_of(table: &[Route]) -> Vec<String> {
std::iter::once("GET /".to_string())
.chain(
table
.iter()
.map(|(method, path, _)| format!("{method} {path}")),
)
.collect()
}
pub fn create_router(state: AppState) -> Router {
create_router_with_config(state, RouterConfig::default())
}
pub fn create_router_with_config(state: AppState, config: RouterConfig) -> Router {
let table = route_table(&config);
let index_routes = route_index_of(&table);
let root_index = index_routes.clone();
let mut router = Router::new().route(
"/",
get(move || {
let routes = root_index.clone();
async move {
Json(serde_json::json!({
"service": "apr serve",
"version": env!("CARGO_PKG_VERSION"),
"routes": routes,
}))
}
}),
);
for (_, path, handler) in table {
router = router.route(path, handler);
}
router = router.fallback(move || {
let routes = index_routes.clone();
async move {
(
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "not_found",
"message": "Route not found. Available routes are listed in `routes`.",
"routes": routes,
})),
)
}
});
router = router.layer(axum::middleware::from_fn(cancel_on_disconnect));
router = router.layer(axum::middleware::from_fn(sanitize_json_rejection));
if config.cors {
router = router.layer(tower_http::cors::CorsLayer::permissive());
}
router = router.layer(tower_http::catch_panic::CatchPanicLayer::custom(
panic_to_json_500,
));
router.with_state(state)
}
pub(crate) fn panic_to_json_500(err: Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response {
use axum::response::IntoResponse as _;
let detail = err
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| err.downcast_ref::<&'static str>().copied())
.unwrap_or("<non-string panic payload>");
eprintln!("apr serve: handler panicked: {detail}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "internal_error",
"message": "The server hit an internal error handling this request. \
This is a bug; the request was not completed.",
})),
)
.into_response()
}
fn sanitized_error_message(status: StatusCode) -> String {
match status {
StatusCode::BAD_REQUEST => {
"Invalid request body. Expected a JSON object matching this endpoint's schema."
.to_string()
},
StatusCode::UNSUPPORTED_MEDIA_TYPE => {
"Expected request with Content-Type: application/json.".to_string()
},
StatusCode::PAYLOAD_TOO_LARGE => "Request body is too large.".to_string(),
StatusCode::METHOD_NOT_ALLOWED => {
"Method not allowed for this route. See the `allow` header.".to_string()
},
StatusCode::UNPROCESSABLE_ENTITY => {
"Invalid request body. Check that the JSON structure matches the expected schema."
.to_string()
},
other => format!(
"Request failed with status {} {}.",
other.as_u16(),
other.canonical_reason().unwrap_or("Error")
),
}
}
async fn sanitize_json_rejection(
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let response = next.run(request).await;
let status = response.status();
if !(status.is_client_error() || status.is_server_error()) {
return response;
}
let already_structured = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| {
ct.starts_with("application/json") || ct.starts_with("application/x-ndjson")
});
if already_structured {
return response;
}
let (parts_for_body, body_in) = response.into_parts();
let raw = axum::body::to_bytes(body_in, usize::MAX)
.await
.unwrap_or_default();
let message = client_visible_reason(&String::from_utf8_lossy(&raw))
.unwrap_or_else(|| sanitized_error_message(status));
let response = axum::response::Response::from_parts(parts_for_body, axum::body::Body::empty());
let body = serde_json::to_vec(&ErrorResponse { error: message })
.unwrap_or_else(|_| br#"{"error":"Request failed."}"#.to_vec());
let (mut parts, _discarded) = response.into_parts();
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
parts.headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
axum::response::Response::from_parts(parts, axum::body::Body::from(body))
}
pub(crate) const CLIENT_VISIBLE_MARKER: &str = "[request] ";
fn client_visible_reason(rejection_body: &str) -> Option<String> {
let reason = rejection_body.split(CLIENT_VISIBLE_MARKER).nth(1)?;
let reason = reason.split('\n').next().unwrap_or(reason);
let reason = reason
.split(" at line ")
.next()
.unwrap_or(reason)
.trim_end_matches(['"', ' ', '.']);
(!reason.is_empty()).then(|| reason.to_string())
}
#[cfg(test)]
mod client_visible_reason_tests {
use super::client_visible_reason;
#[test]
fn unmarked_serde_text_stays_hidden() {
assert_eq!(
client_visible_reason(
"Failed to deserialize the JSON body into the target type: \
missing field `messages` at line 1 column 42"
),
None
);
}
#[test]
fn marked_reason_is_extracted_without_the_serde_frame() {
let extracted = client_visible_reason(
"Failed to deserialize the JSON body into the target type: n: \
[request] n must be 1: this server returns exactly one choice per request",
)
.expect("marked reason is surfaced");
assert!(extracted.starts_with("n must be 1"));
assert!(
!extracted.contains("deserialize"),
"the serde frame must be stripped: {extracted}"
);
}
#[test]
fn serde_position_suffix_is_stripped() {
let extracted = client_visible_reason(
"Failed to deserialize the JSON body into the target type: n: \
[request] n must be 1: send 3 requests instead at line 1 column 84",
)
.expect("marked reason is surfaced");
assert_eq!(extracted, "n must be 1: send 3 requests instead");
}
}
fn server_uptime_sec() -> f64 {
static SERVER_START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
SERVER_START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs_f64()
}
fn force_loading() -> bool {
std::env::var("APR_TEST_FORCE_LOADING").is_ok_and(|v| v == "1")
}
fn build_health_response(state: &AppState) -> HealthResponse {
let mut compute_mode = "cpu";
#[cfg(feature = "gpu")]
if state.has_gpu_model() || state.has_cached_model() {
compute_mode = "gpu";
}
#[cfg(feature = "cuda")]
if state.has_cuda_model() {
compute_mode = "gpu";
}
let model_loaded = state.model_loaded();
let status = if force_loading() || !model_loaded {
"loading"
} else {
"ok"
};
HealthResponse {
status: status.to_string(),
version: crate::VERSION.to_string(),
compute_mode: compute_mode.to_string(),
model_loaded,
uptime_sec: server_uptime_sec(),
}
}
fn health_status_code(body: &HealthResponse) -> StatusCode {
if body.status == "ok" {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
async fn health_handler(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
if state.is_verbose() {
eprintln!("[VERBOSE] GET /health");
}
let body = build_health_response(&state);
let code = health_status_code(&body);
if state.is_verbose() {
eprintln!("[VERBOSE] GET /health -> {} status={}", code, body.status);
}
(code, Json(body))
}
async fn health_live_handler(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
if state.is_verbose() {
eprintln!("[VERBOSE] GET /health/live");
}
(StatusCode::OK, Json(build_health_response(&state)))
}
async fn health_ready_handler(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
if state.is_verbose() {
eprintln!("[VERBOSE] GET /health/ready");
}
let body = build_health_response(&state);
let code = if body.status == "ok" && body.model_loaded {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
(code, Json(body))
}
async fn metrics_handler(State(state): State<AppState>) -> String {
state.metrics.to_prometheus()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DispatchMetricsResponse {
pub cpu_dispatches: usize,
pub gpu_dispatches: usize,
pub total_dispatches: usize,
pub gpu_ratio: f64,
pub cpu_latency_p50_us: f64,
pub cpu_latency_p95_us: f64,
pub cpu_latency_p99_us: f64,
pub gpu_latency_p50_us: f64,
pub gpu_latency_p95_us: f64,
pub gpu_latency_p99_us: f64,
pub cpu_latency_mean_us: f64,
pub gpu_latency_mean_us: f64,
pub cpu_latency_min_us: u64,
pub cpu_latency_max_us: u64,
pub gpu_latency_min_us: u64,
pub gpu_latency_max_us: u64,
pub cpu_latency_variance_us: f64,
pub cpu_latency_stddev_us: f64,
pub gpu_latency_variance_us: f64,
pub gpu_latency_stddev_us: f64,
pub bucket_boundaries_us: Vec<String>,
pub cpu_latency_bucket_counts: Vec<usize>,
pub gpu_latency_bucket_counts: Vec<usize>,
pub throughput_rps: f64,
pub elapsed_seconds: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ServerMetricsResponse {
pub throughput_tok_per_sec: f64,
pub latency_p50_ms: f64,
pub latency_p95_ms: f64,
pub latency_p99_ms: f64,
pub gpu_memory_used_bytes: u64,
pub gpu_memory_total_bytes: u64,
pub gpu_utilization_percent: u32,
pub cuda_path_active: bool,
pub batch_size: usize,
pub queue_depth: usize,
pub total_tokens: u64,
pub total_requests: u64,
pub uptime_secs: u64,
pub model_name: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DispatchMetricsQuery {
#[serde(default)]
pub format: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DispatchResetResponse {
pub success: bool,
pub message: String,
}
#[cfg(feature = "gpu")]
async fn dispatch_reset_handler(State(state): State<AppState>) -> axum::response::Response {
use axum::response::IntoResponse;
if let Some(metrics) = state.dispatch_metrics() {
metrics.reset();
Json(DispatchResetResponse {
success: true,
message: "Metrics reset successfully".to_string(),
})
.into_response()
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Dispatch metrics not available. No GPU model configured.".to_string(),
}),
)
.into_response()
}
}
#[cfg(not(feature = "gpu"))]
async fn dispatch_reset_handler(State(_state): State<AppState>) -> axum::response::Response {
use axum::response::IntoResponse;
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Dispatch metrics not available. GPU feature not enabled.".to_string(),
}),
)
.into_response()
}
fn served_model_name(state: &AppState) -> String {
if let Some(stem) = state
.model_source()
.and_then(crate::api::ModelSourceInfo::path)
.and_then(|p| {
std::path::Path::new(p)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
})
.filter(|s| !s.is_empty())
{
return stem;
}
if let Some(id) = state.default_model_id.clone() {
return id;
}
if state.model_loaded() {
return "default".to_string();
}
"N/A".to_string()
}
fn measured_latency_percentiles(state: &AppState) -> (f64, f64, f64) {
#[cfg(feature = "gpu")]
if let Some(dispatch) = state.dispatch_metrics() {
if dispatch.gpu_dispatches() > 0 {
return (
dispatch.gpu_latency_p50_us() / 1000.0,
dispatch.gpu_latency_p95_us() / 1000.0,
dispatch.gpu_latency_p99_us() / 1000.0,
);
}
if dispatch.cpu_dispatches() > 0 {
return (
dispatch.cpu_latency_p50_us() / 1000.0,
dispatch.cpu_latency_p95_us() / 1000.0,
dispatch.cpu_latency_p99_us() / 1000.0,
);
}
}
state
.metrics
.latency_percentiles()
.map_or((0.0, 0.0, 0.0), |p| (p.p50_ms, p.p95_ms, p.p99_ms))
}
#[cfg(feature = "gpu")]
async fn server_metrics_handler(State(state): State<AppState>) -> Json<ServerMetricsResponse> {
let snapshot = state.metrics.snapshot();
let (latency_p50_ms, latency_p95_ms, latency_p99_ms) = measured_latency_percentiles(&state);
let (gpu_dispatches, cuda_path_active) = state
.dispatch_metrics()
.map_or((0, false), |dispatch| {
let gpu = dispatch.gpu_dispatches();
(gpu, gpu > 0)
});
let (gpu_memory_used_bytes, gpu_memory_total_bytes): (u64, u64) =
if let Some(model) = state.cached_model() {
let used = model.gpu_cache_memory() as u64;
let total = 24 * 1024 * 1024 * 1024u64;
(used, total)
} else {
(0, 0)
};
let gpu_utilization_percent = if let Some(dispatch) = state.dispatch_metrics() {
let total = dispatch.total_dispatches();
if total > 0 {
((gpu_dispatches as f64 / total as f64) * 100.0) as u32
} else {
0
}
} else {
0
};
let (batch_size, queue_depth) = if let Some(config) = state.batch_config() {
(config.optimal_batch, config.queue_size)
} else {
(1, 0)
};
let model_name = served_model_name(&state);
Json(ServerMetricsResponse {
throughput_tok_per_sec: snapshot.tokens_per_sec,
latency_p50_ms,
latency_p95_ms,
latency_p99_ms,
gpu_memory_used_bytes,
gpu_memory_total_bytes,
gpu_utilization_percent,
cuda_path_active,
batch_size,
queue_depth,
total_tokens: snapshot.total_tokens as u64,
total_requests: snapshot.total_requests as u64,
uptime_secs: snapshot.uptime_secs,
model_name,
})
}
#[cfg(not(feature = "gpu"))]
async fn server_metrics_handler(State(state): State<AppState>) -> Json<ServerMetricsResponse> {
let snapshot = state.metrics.snapshot();
let (latency_p50_ms, latency_p95_ms, latency_p99_ms) = measured_latency_percentiles(&state);
Json(ServerMetricsResponse {
throughput_tok_per_sec: snapshot.tokens_per_sec,
latency_p50_ms,
latency_p95_ms,
latency_p99_ms,
gpu_memory_used_bytes: 0,
gpu_memory_total_bytes: 0,
gpu_utilization_percent: 0,
cuda_path_active: false,
batch_size: 1,
queue_depth: 0,
total_tokens: snapshot.total_tokens as u64,
total_requests: snapshot.total_requests as u64,
uptime_secs: snapshot.uptime_secs,
model_name: served_model_name(&state),
})
}