refactor: split large files into modules

- Split config.rs into config/{mod,delete,get,put}.rs
- Split routes_api.rs into routes_api/{mod,helpers,providers,routes}.rs
- Split passthrough/handlers.rs into handlers/{mod,errors,generic,messages}.rs
- Split streaming.rs into streaming/{mod,handler,helpers}.rs
- Split main_helpers/async_main/admin.rs into admin/{mod,config,tasks}.rs
- Minor cleanups in chat_completions backends, token_counting, tests
- Add docs/TEST_PARITY_LITELLM.md

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-07-13 06:11:40 -05:00
co-authored by Claude
parent d12309d701
commit 86d01d29fd
30 changed files with 3950 additions and 2015 deletions
@@ -0,0 +1,137 @@
use crate::admin::state::SharedState;
use axum::{
extract::{ConnectInfo, Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use std::net::SocketAddr;
/// DELETE /admin/api/config/overrides/:key -- remove a single override.
pub(crate) async fn delete_config_override(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(key): Path<String>,
) -> impl IntoResponse {
let key_clone = key.clone();
match crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::delete_config_override(conn, &key_clone)
})
.await
{
Some(Ok(true)) => {
let restored = match key.as_str() {
"redact_secrets" => {
let env_default = shared.runtime_defaults.redact_secrets;
if let Ok(mut config) = shared.runtime_config.write() {
config.redact_secrets = env_default;
}
Some(env_default.to_string())
}
"log_bodies" => {
let env_default = shared.runtime_defaults.log_bodies;
if let Ok(mut config) = shared.runtime_config.write() {
config.log_bodies = env_default;
}
Some(env_default.to_string())
}
"anthropic_thinking_repair" => {
let env_default = shared.runtime_defaults.anthropic_thinking_repair;
if let Ok(mut config) = shared.runtime_config.write() {
config.anthropic_thinking_repair = env_default;
}
Some(env_default.to_string())
}
"pxpipe_compress" => {
let env_default = shared.runtime_defaults.pxpipe_compress;
if let Ok(mut config) = shared.runtime_config.write() {
config.pxpipe_compress = env_default;
}
Some(env_default.to_string())
}
"rtk_compress" => {
let env_default = shared.runtime_defaults.rtk_compress;
if let Ok(mut config) = shared.runtime_config.write() {
config.rtk_compress = env_default;
}
Some(env_default.to_string())
}
"rtk_models" => {
let env_default = shared.runtime_defaults.rtk_models.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.rtk_models = env_default.clone();
}
Some(env_default)
}
"pxpipe_models" => {
let env_default = shared.runtime_defaults.pxpipe_models.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.pxpipe_models = env_default.clone();
}
Some(env_default)
}
"forward_client_auth" => {
let env_default = shared.runtime_defaults.forward_client_auth;
if let Ok(mut config) = shared.runtime_config.write() {
config.forward_client_auth = env_default;
}
Some(env_default.to_string())
}
"tool_guardrail_mode" => {
let env_default = shared.runtime_defaults.tool_guardrail_mode.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.tool_guardrail_mode = env_default.clone();
}
Some(env_default)
}
"optimizer_mode" => {
let env_default = shared.runtime_defaults.optimizer_mode.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.optimizer_mode = env_default.clone();
}
Some(env_default)
}
_ => None,
};
if let Some(value) = restored {
let _ = shared
.events_tx
.send(crate::admin::state::AdminEvent::ConfigChanged {
key: key.clone(),
value,
});
}
super::super::emit_audit(
&shared,
crate::admin::db::AuditEntry {
id: None,
timestamp: None,
action: "config_deleted".into(),
target_type: "config".into(),
target_id: Some(key.clone()),
detail: None,
source_ip: Some(addr.ip().to_string()),
},
);
(StatusCode::OK, Json(serde_json::json!({"deleted": key}))).into_response()
}
Some(Ok(false)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "override not found"})),
)
.into_response(),
Some(Err(e)) => {
tracing::error!(error = %e, "delete_config_override failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal database error"})),
)
.into_response()
}
None => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal error"})),
)
.into_response(),
}
}
+100
View File
@@ -0,0 +1,100 @@
use crate::admin::state::SharedState;
use axum::{extract::State, Json};
/// GET /admin/api/config -- effective config (env defaults + overrides).
pub(crate) async fn get_config(State(shared): State<SharedState>) -> Json<serde_json::Value> {
let (
log_level,
log_bodies,
redact_secrets,
anthropic_thinking_repair,
pxpipe_compress,
pxpipe_models,
rtk_compress,
rtk_models,
forward_client_auth,
tool_guardrail_mode,
optimizer_mode,
backends,
) = {
let config = shared
.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner());
let mut backends = serde_json::Map::new();
for (name, mapping) in &config.model_mappings {
backends.insert(
name.clone(),
serde_json::json!({
"big_model": mapping.big_model,
"small_model": mapping.small_model,
}),
);
}
(
config.log_level.clone(),
config.log_bodies,
config.redact_secrets,
config.anthropic_thinking_repair,
config.pxpipe_compress,
config.pxpipe_models.clone(),
config.rtk_compress,
config.rtk_models.clone(),
config.forward_client_auth,
config.tool_guardrail_mode.clone(),
config.optimizer_mode.clone(),
backends,
)
};
let overrides = crate::admin::state::with_db(&shared.db, |conn| {
crate::admin::db::get_config_overrides(conn).unwrap_or_default()
})
.await
.unwrap_or_default();
let override_keys: Vec<String> = overrides.iter().map(|(k, _, _)| k.clone()).collect();
let pxpipe_available_models =
crate::pxpipe::available_vision_models(&shared.provider_catalog, &pxpipe_models);
Json(serde_json::json!({
"log_level": log_level,
"log_bodies": log_bodies,
"redact_secrets": redact_secrets,
"anthropic_thinking_repair": anthropic_thinking_repair,
"pxpipe_compress": pxpipe_compress,
"pxpipe_models": pxpipe_models,
"pxpipe_available_models": pxpipe_available_models,
"rtk_compress": rtk_compress,
"rtk_models": rtk_models,
"forward_client_auth": forward_client_auth,
"tool_guardrail_mode": tool_guardrail_mode,
"optimizer_mode": optimizer_mode,
"backends": backends,
"overridden_keys": override_keys,
}))
}
/// GET /admin/api/config/overrides -- only SQLite overrides.
pub(crate) async fn get_config_overrides(
State(shared): State<SharedState>,
) -> Json<serde_json::Value> {
let overrides = crate::admin::state::with_db(&shared.db, |conn| {
crate::admin::db::get_config_overrides(conn).unwrap_or_default()
})
.await
.unwrap_or_default();
let entries: Vec<serde_json::Value> = overrides
.into_iter()
.map(|(k, v, updated_at)| {
serde_json::json!({
"key": k,
"value": v,
"updated_at": updated_at,
})
})
.collect();
Json(serde_json::json!({ "overrides": entries }))
}
@@ -0,0 +1,7 @@
mod delete;
mod get;
mod put;
pub(crate) use delete::delete_config_override;
pub(crate) use get::{get_config, get_config_overrides};
pub(crate) use put::put_config;
@@ -1,107 +1,21 @@
use crate::admin::state::SharedState;
use axum::{
extract::{ConnectInfo, Path, State},
extract::{ConnectInfo, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use std::net::SocketAddr;
/// GET /admin/api/config -- effective config (env defaults + overrides).
pub(super) async fn get_config(State(shared): State<SharedState>) -> Json<serde_json::Value> {
// Clone config snapshot and drop the read guard before any .await points.
// std::sync::RwLockReadGuard is !Send, cannot be held across awaits.
let (
log_level,
log_bodies,
redact_secrets,
anthropic_thinking_repair,
pxpipe_compress,
pxpipe_models,
rtk_compress,
rtk_models,
forward_client_auth,
tool_guardrail_mode,
optimizer_mode,
backends,
) = {
let config = shared
.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner());
let mut backends = serde_json::Map::new();
for (name, mapping) in &config.model_mappings {
backends.insert(
name.clone(),
serde_json::json!({
"big_model": mapping.big_model,
"small_model": mapping.small_model,
}),
);
}
(
config.log_level.clone(),
config.log_bodies,
config.redact_secrets,
config.anthropic_thinking_repair,
config.pxpipe_compress,
config.pxpipe_models.clone(),
config.rtk_compress,
config.rtk_models.clone(),
config.forward_client_auth,
config.tool_guardrail_mode.clone(),
config.optimizer_mode.clone(),
backends,
)
};
// Get overrides to mark which fields are overridden.
let overrides = crate::admin::state::with_db(&shared.db, |conn| {
crate::admin::db::get_config_overrides(conn).unwrap_or_default()
})
.await
.unwrap_or_default();
let override_keys: Vec<String> = overrides.iter().map(|(k, _, _)| k.clone()).collect();
// Computed before the json! macro consumes `pxpipe_models` by move.
let pxpipe_available_models =
crate::pxpipe::available_vision_models(&shared.provider_catalog, &pxpipe_models);
Json(serde_json::json!({
"log_level": log_level,
"log_bodies": log_bodies,
"redact_secrets": redact_secrets,
"anthropic_thinking_repair": anthropic_thinking_repair,
"pxpipe_compress": pxpipe_compress,
"pxpipe_models": pxpipe_models,
// Vision-capable Claude models (+ current out-of-list scope entries) the
// UI offers as per-model scope toggles.
"pxpipe_available_models": pxpipe_available_models,
"rtk_compress": rtk_compress,
"rtk_models": rtk_models,
"forward_client_auth": forward_client_auth,
"tool_guardrail_mode": tool_guardrail_mode,
"optimizer_mode": optimizer_mode,
"backends": backends,
"overridden_keys": override_keys,
}))
}
/// PUT /admin/api/config -- update config overrides. Partial JSON body.
pub(super) async fn put_config(
pub(crate) async fn put_config(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// Collect the key-value pairs to persist, then do all SQLite I/O
// before touching in-memory state. This avoids holding the async
// MutexGuard across block_in_place.
let mut db_writes: Vec<(String, String)> = Vec::new();
if let Some(level) = body.get("log_level").and_then(|v| v.as_str()) {
// Allowlist: trace-level logging exposes HTTP headers (including API
// keys) in log output. Arbitrary filter directives could also be used
// to selectively leak data. Restrict to safe levels only.
const ALLOWED_LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug"];
let normalized = level.trim().to_lowercase();
if !ALLOWED_LOG_LEVELS.contains(&normalized.as_str()) {
@@ -161,21 +75,10 @@ pub(super) async fn put_config(
db_writes.push(("rtk_compress".to_string(), val.to_string()));
}
if let Some(val) = body.get("rtk_models").and_then(|v| v.as_str()) {
// Normalize the CSV (trim entries, drop empties); empty = all models.
let normalized = crate::config::helpers::normalize_csv(val);
db_writes.push(("rtk_models".to_string(), normalized));
}
if let Some(val) = body.get("forward_client_auth").and_then(|v| v.as_bool()) {
// Same rule enforced at startup (main_helpers::async_main) for
// statically-configured backends: 2+ distinct PROXY_API_KEYS entries
// with no PROXY_OPEN_RELAY would let different callers each redirect
// the upstream Anthropic credential. This is the only path that can
// enable the toggle *after* boot (live, no restart), so it must be
// re-checked here -- otherwise this admin route would silently
// reopen exactly the misconfiguration the startup panic exists to
// block. Reads the same ALLOWED_KEY_HASHES/OPEN_RELAY statics
// validate_auth uses, not a re-parse of the env vars, so it can't
// diverge from what a request actually experiences.
if val
&& crate::server::middleware::forward_client_auth_misconfigured(
crate::server::middleware::distinct_static_key_count(),
@@ -223,7 +126,6 @@ pub(super) async fn put_config(
}
}
if let Some(backends) = body.get("backends").and_then(|v| v.as_object()) {
// Read current config to validate backend names exist
let config = shared
.runtime_config
.read()
@@ -231,7 +133,7 @@ pub(super) async fn put_config(
for (name, settings) in backends {
if config.model_mappings.contains_key(name) {
if let Some(big) = settings.get("big_model").and_then(|v| v.as_str()) {
if !super::is_safe_model_name(big) {
if !super::super::is_safe_model_name(big) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
@@ -243,7 +145,7 @@ pub(super) async fn put_config(
db_writes.push((format!("{name}.big_model"), big.to_string()));
}
if let Some(small) = settings.get("small_model").and_then(|v| v.as_str()) {
if !super::is_safe_model_name(small) {
if !super::super::is_safe_model_name(small) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
@@ -258,14 +160,8 @@ pub(super) async fn put_config(
}
}
// Serialize config writes so concurrent requests cannot interleave
// Phase 1 (SQLite) and Phase 2 (in-memory), which would leave them
// inconsistent.
let _config_guard = shared.config_write_lock.lock().await;
// Phase 1: Persist to SQLite first. If the process crashes between
// phases, the database is the source of truth and config is restored
// on restart. Reversing the order would lose updates on crash.
{
let writes = db_writes.clone();
crate::admin::state::with_db(&shared.db, move |conn| {
@@ -276,14 +172,12 @@ pub(super) async fn put_config(
.await;
}
// Phase 2: Apply to in-memory config (no async lock held)
{
let mut config = shared
.runtime_config
.write()
.unwrap_or_else(|e| e.into_inner());
// Audit log: capture old values before applying changes
for (key, new_value) in &db_writes {
let old_value = match key.as_str() {
"log_level" => config.log_level.clone(),
@@ -378,7 +272,6 @@ pub(super) async fn put_config(
drop(_config_guard);
// Broadcast config changes.
for (key, value) in &db_writes {
let _ = shared
.events_tx
@@ -386,7 +279,7 @@ pub(super) async fn put_config(
key: key.clone(),
value: value.clone(),
});
super::emit_audit(
super::super::emit_audit(
&shared,
crate::admin::db::AuditEntry {
id: None,
@@ -409,158 +302,3 @@ pub(super) async fn put_config(
)
.into_response()
}
/// GET /admin/api/config/overrides -- only SQLite overrides.
pub(super) async fn get_config_overrides(
State(shared): State<SharedState>,
) -> Json<serde_json::Value> {
let overrides = crate::admin::state::with_db(&shared.db, |conn| {
crate::admin::db::get_config_overrides(conn).unwrap_or_default()
})
.await
.unwrap_or_default();
let entries: Vec<serde_json::Value> = overrides
.into_iter()
.map(|(k, v, updated_at)| {
serde_json::json!({
"key": k,
"value": v,
"updated_at": updated_at,
})
})
.collect();
Json(serde_json::json!({ "overrides": entries }))
}
/// DELETE /admin/api/config/overrides/:key -- remove a single override.
pub(super) async fn delete_config_override(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(key): Path<String>,
) -> impl IntoResponse {
let key_clone = key.clone();
match crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::delete_config_override(conn, &key_clone)
})
.await
{
Some(Ok(true)) => {
// Restore the runtime value to its pre-override default. Without this,
// deleting an override leaves the overridden value live until restart.
let restored = match key.as_str() {
"redact_secrets" => {
let env_default = shared.runtime_defaults.redact_secrets;
if let Ok(mut config) = shared.runtime_config.write() {
config.redact_secrets = env_default;
}
Some(env_default.to_string())
}
"log_bodies" => {
let env_default = shared.runtime_defaults.log_bodies;
if let Ok(mut config) = shared.runtime_config.write() {
config.log_bodies = env_default;
}
Some(env_default.to_string())
}
"anthropic_thinking_repair" => {
let env_default = shared.runtime_defaults.anthropic_thinking_repair;
if let Ok(mut config) = shared.runtime_config.write() {
config.anthropic_thinking_repair = env_default;
}
Some(env_default.to_string())
}
"pxpipe_compress" => {
let env_default = shared.runtime_defaults.pxpipe_compress;
if let Ok(mut config) = shared.runtime_config.write() {
config.pxpipe_compress = env_default;
}
Some(env_default.to_string())
}
"rtk_compress" => {
let env_default = shared.runtime_defaults.rtk_compress;
if let Ok(mut config) = shared.runtime_config.write() {
config.rtk_compress = env_default;
}
Some(env_default.to_string())
}
"rtk_models" => {
let env_default = shared.runtime_defaults.rtk_models.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.rtk_models = env_default.clone();
}
Some(env_default)
}
"pxpipe_models" => {
let env_default = shared.runtime_defaults.pxpipe_models.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.pxpipe_models = env_default.clone();
}
Some(env_default)
}
"forward_client_auth" => {
let env_default = shared.runtime_defaults.forward_client_auth;
if let Ok(mut config) = shared.runtime_config.write() {
config.forward_client_auth = env_default;
}
Some(env_default.to_string())
}
"tool_guardrail_mode" => {
let env_default = shared.runtime_defaults.tool_guardrail_mode.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.tool_guardrail_mode = env_default.clone();
}
Some(env_default)
}
"optimizer_mode" => {
let env_default = shared.runtime_defaults.optimizer_mode.clone();
if let Ok(mut config) = shared.runtime_config.write() {
config.optimizer_mode = env_default.clone();
}
Some(env_default)
}
_ => None,
};
if let Some(value) = restored {
let _ = shared
.events_tx
.send(crate::admin::state::AdminEvent::ConfigChanged {
key: key.clone(),
value,
});
}
super::emit_audit(
&shared,
crate::admin::db::AuditEntry {
id: None,
timestamp: None,
action: "config_deleted".into(),
target_type: "config".into(),
target_id: Some(key.clone()),
detail: None,
source_ip: Some(addr.ip().to_string()),
},
);
(StatusCode::OK, Json(serde_json::json!({"deleted": key}))).into_response()
}
Some(Ok(false)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "override not found"})),
)
.into_response(),
Some(Err(e)) => {
tracing::error!(error = %e, "delete_config_override failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal database error"})),
)
.into_response()
}
None => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal error"})),
)
.into_response(),
}
}
-562
View File
@@ -1,562 +0,0 @@
// Route handlers for the routes + route_providers CRUD API.
pub use crate::admin::db::{ReorderOutcome, RoutePatch, RouteProviderRow, RouteRow};
use crate::admin::state::SharedState;
use axum::{
extract::{ConnectInfo, Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Deserialize;
use std::net::SocketAddr;
// ── Response types ────────────────────────────────────────────────────────────
#[derive(serde::Serialize)]
struct RouteResponse {
id: String,
name: String,
description: Option<String>,
strategy: String,
rpm: Option<u32>,
tpm: Option<u64>,
budget_usd: Option<f64>,
enabled: bool,
guardrail_mode: Option<String>,
pxpipe_compress: Option<bool>,
pxpipe_models: Option<String>,
redact_secrets: Option<bool>,
position: i32,
provider_count: usize,
created_at: String,
updated_at: String,
}
#[derive(serde::Serialize)]
struct RouteProviderResponse {
id: String,
route_id: String,
backend_id: String,
backend_name: String,
provider_id: String,
models: Vec<String>,
priority: i32,
enabled: bool,
}
// ── Request types ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CreateRouteRequest {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_strategy")]
pub strategy: String,
pub rpm: Option<u32>,
pub tpm: Option<u64>,
pub budget_usd: Option<f64>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub guardrail_mode: Option<String>,
#[serde(default)]
pub pxpipe_compress: Option<bool>,
#[serde(default)]
pub pxpipe_models: Option<String>,
#[serde(default)]
pub redact_secrets: Option<bool>,
#[serde(default)]
pub position: i32,
}
fn default_strategy() -> String {
"failover".into()
}
#[derive(Deserialize)]
pub struct AddRouteProviderRequest {
pub backend_id: String,
#[serde(default = "default_models")]
pub models: Vec<String>,
#[serde(default)]
pub priority: i32,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_models() -> Vec<String> {
vec!["*".into()]
}
fn default_true() -> bool {
true
}
#[derive(Deserialize)]
pub struct UpdateRouteProviderRequest {
pub models: Option<Vec<String>>,
pub priority: Option<i32>,
pub enabled: Option<bool>,
}
#[derive(Deserialize)]
pub struct ReorderRouteProvidersRequest {
pub provider_ids: Vec<String>,
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn route_to_response(route: &RouteRow, provider_count: usize) -> RouteResponse {
RouteResponse {
id: route.id.clone(),
name: route.name.clone(),
description: route.description.clone(),
strategy: route.strategy.clone(),
rpm: route.rpm,
tpm: route.tpm,
budget_usd: route.budget_usd,
enabled: route.enabled,
guardrail_mode: route.guardrail_mode.clone(),
pxpipe_compress: route.pxpipe_compress,
pxpipe_models: route.pxpipe_models.clone(),
redact_secrets: route.redact_secrets,
position: route.position,
provider_count,
created_at: route.created_at.clone(),
updated_at: route.updated_at.clone(),
}
}
fn db_error_status(e: &rusqlite::Error) -> StatusCode {
if let rusqlite::Error::SqliteFailure(ref err, _) = e {
if err.code == rusqlite::ErrorCode::ConstraintViolation {
return StatusCode::CONFLICT;
}
}
StatusCode::INTERNAL_SERVER_ERROR
}
fn ok_json<T: serde::Serialize>(val: T) -> axum::response::Response {
(StatusCode::OK, Json(serde_json::to_value(val).unwrap())).into_response()
}
fn err_json(status: StatusCode, msg: impl Into<String>) -> axum::response::Response {
(status, Json(serde_json::json!({ "error": msg.into() }))).into_response()
}
fn build_provider_responses(
providers: &[RouteProviderRow],
backends: &[crate::admin::db::ManagedBackendRow],
) -> Vec<RouteProviderResponse> {
let backend_map: std::collections::HashMap<&str, &crate::admin::db::ManagedBackendRow> =
backends.iter().map(|b| (b.id.as_str(), b)).collect();
providers
.iter()
.map(|p| {
let backend = backend_map.get(p.backend_id.as_str());
RouteProviderResponse {
id: p.id.clone(),
route_id: p.route_id.clone(),
backend_id: p.backend_id.clone(),
backend_name: backend.map(|b| b.name.clone()).unwrap_or_default(),
provider_id: backend.map(|b| b.provider_id.clone()).unwrap_or_default(),
models: p.models.clone(),
priority: p.priority,
enabled: p.enabled,
}
})
.collect()
}
fn audit(
shared: &SharedState,
source_ip: Option<String>,
action: &str,
target_type: &str,
target_id: String,
detail: Option<String>,
) {
super::emit_audit(
shared,
crate::admin::db::AuditEntry {
id: None,
timestamp: None,
action: action.into(),
target_type: target_type.into(),
target_id: Some(target_id),
detail,
source_ip,
},
);
}
// ── Route CRUD ────────────────────────────────────────────────────────────────
pub(super) async fn list_routes(State(shared): State<SharedState>) -> axum::response::Response {
let result = crate::admin::state::with_db(&shared.db, |conn| {
let routes = crate::admin::db::list_routes(conn)?;
let mut resp = Vec::with_capacity(routes.len());
for r in &routes {
let count = crate::admin::db::count_route_providers(conn, &r.id).unwrap_or(0);
resp.push(route_to_response(r, count));
}
Ok::<_, rusqlite::Error>(resp)
})
.await;
match result {
Some(Ok(resp)) => ok_json(serde_json::json!({ "routes": resp })),
_ => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to list routes"),
}
}
pub(super) async fn create_route(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Json(body): Json<CreateRouteRequest>,
) -> axum::response::Response {
if body.name.trim().is_empty() {
return err_json(StatusCode::BAD_REQUEST, "name is required");
}
let now = crate::admin::db::now_iso8601();
let row = RouteRow {
id: uuid::Uuid::new_v4().to_string(),
name: body.name.trim().to_string(),
description: body.description,
strategy: body.strategy,
rpm: body.rpm,
tpm: body.tpm,
budget_usd: body.budget_usd,
enabled: body.enabled,
guardrail_mode: body.guardrail_mode,
pxpipe_compress: body.pxpipe_compress,
pxpipe_models: body.pxpipe_models,
redact_secrets: body.redact_secrets,
position: body.position,
created_at: now.clone(),
updated_at: now,
};
let row_clone = row.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::insert_route(conn, &row_clone)
})
.await;
match result {
Some(Ok(())) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_created",
"route",
row.name.clone(),
None,
);
super::rebuild_route_router(&shared).await;
(
StatusCode::CREATED,
Json(serde_json::to_value(route_to_response(&row, 0)).unwrap()),
)
.into_response()
}
Some(Err(e)) => err_json(db_error_status(&e), e.to_string()),
None => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create route"),
}
}
pub(super) async fn update_route(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(id): Path<String>,
Json(body): Json<RoutePatch>,
) -> axum::response::Response {
let id_clone = id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
let updated = crate::admin::db::update_route(conn, &id_clone, &body)?;
if !updated {
return Ok::<_, rusqlite::Error>((false, None, 0));
}
let route = crate::admin::db::get_route(conn, &id_clone).ok().flatten();
let count = crate::admin::db::count_route_providers(conn, &id_clone).unwrap_or(0);
Ok((true, route, count))
})
.await;
match result {
Some(Ok((true, Some(r), count))) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_updated",
"route",
id,
None,
);
super::rebuild_route_router(&shared).await;
ok_json(route_to_response(&r, count))
}
Some(Ok((true, None, _))) => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"route not found after update",
),
Some(Ok((false, _, _))) => err_json(StatusCode::NOT_FOUND, "route not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to update route"),
}
}
pub(super) async fn delete_route(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(id): Path<String>,
) -> axum::response::Response {
let id_clone = id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::delete_route(conn, &id_clone)
})
.await;
match result {
Some(Ok(true)) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_deleted",
"route",
id,
None,
);
super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "ok": true }))
}
Some(Ok(false)) => err_json(StatusCode::NOT_FOUND, "route not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete route"),
}
}
// ── Route Providers CRUD ──────────────────────────────────────────────────────
pub(super) async fn list_route_providers_handler(
State(shared): State<SharedState>,
Path(route_id): Path<String>,
) -> axum::response::Response {
let result = crate::admin::state::with_db(&shared.db, move |conn| {
if crate::admin::db::get_route(conn, &route_id)
.ok()
.flatten()
.is_none()
{
return Ok::<_, rusqlite::Error>(None);
}
let providers = crate::admin::db::list_route_providers(conn, &route_id)?;
let backends = crate::admin::db::list_managed_backends(conn).unwrap_or_default();
Ok(Some(build_provider_responses(&providers, &backends)))
})
.await;
match result {
Some(Ok(Some(resp))) => ok_json(serde_json::json!({ "providers": resp })),
Some(Ok(None)) => err_json(StatusCode::NOT_FOUND, "route not found"),
_ => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to list route providers",
),
}
}
pub(super) async fn add_route_provider_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(route_id): Path<String>,
Json(body): Json<AddRouteProviderRequest>,
) -> axum::response::Response {
let route_id_clone = route_id.clone();
let backend_id = body.backend_id.clone();
let models = body.models.clone();
let priority = body.priority;
let enabled = body.enabled;
let backend_id_for_db = backend_id.clone();
let backend_id_check = backend_id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
if crate::admin::db::get_route(conn, &route_id_clone)
.ok()
.flatten()
.is_none()
{
return Ok::<_, rusqlite::Error>(Err::<(), String>("route not found".into()));
}
if !crate::admin::db::managed_backend_exists(conn, &backend_id_check)? {
return Ok(Err::<(), String>("backend not found".into()));
}
crate::admin::db::add_route_provider(
conn,
&route_id_clone,
&backend_id_for_db,
&models,
priority,
enabled,
)?;
Ok(Ok::<(), String>(()))
})
.await;
match result {
Some(Ok(Ok(()))) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_provider_added",
"route_provider",
route_id,
Some(format!("backend_id={}", backend_id)),
);
super::rebuild_route_router(&shared).await;
(StatusCode::CREATED, Json(serde_json::json!({ "ok": true }))).into_response()
}
Some(Ok(Err(msg))) => err_json(
if msg == "route not found" {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_REQUEST
},
msg,
),
Some(Err(e)) => err_json(db_error_status(&e), e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to add route provider",
),
}
}
pub(super) async fn update_route_provider_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path((route_id, provider_id)): Path<(String, String)>,
Json(body): Json<UpdateRouteProviderRequest>,
) -> axum::response::Response {
let provider_id_clone = provider_id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::update_route_provider(
conn,
&provider_id_clone,
body.models.as_deref(),
body.priority,
body.enabled,
)
})
.await;
match result {
Some(Ok(true)) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_provider_updated",
"route_provider",
provider_id,
Some(format!("route_id={}", route_id)),
);
super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "ok": true }))
}
Some(Ok(false)) => err_json(StatusCode::NOT_FOUND, "route provider not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to update route provider",
),
}
}
pub(super) async fn reorder_route_providers_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(route_id): Path<String>,
Json(body): Json<ReorderRouteProvidersRequest>,
) -> axum::response::Response {
let route_id_clone = route_id.clone();
let ordered = body.provider_ids.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
if crate::admin::db::get_route(conn, &route_id_clone)
.ok()
.flatten()
.is_none()
{
return Ok::<_, rusqlite::Error>(None);
}
let outcome = crate::admin::db::reorder_route_providers(conn, &route_id_clone, &ordered)?;
match outcome {
ReorderOutcome::Mismatch => Ok(Some(Err::<_, ()>(()))),
ReorderOutcome::Ok(rows) => {
let backends = crate::admin::db::list_managed_backends(conn).unwrap_or_default();
Ok(Some(Ok(build_provider_responses(&rows, &backends))))
}
}
})
.await;
match result {
Some(Ok(Some(Ok(providers)))) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_providers_reordered",
"route",
route_id,
Some(format!("count={}", providers.len())),
);
super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "providers": providers }))
}
Some(Ok(Some(Err(())))) => err_json(
StatusCode::BAD_REQUEST,
"provider_ids must match the route's current provider set exactly",
),
Some(Ok(None)) => err_json(StatusCode::NOT_FOUND, "route not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to reorder route providers",
),
}
}
pub(super) async fn remove_route_provider_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path((_route_id, provider_id)): Path<(String, String)>,
) -> axum::response::Response {
let provider_id_clone = provider_id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::remove_route_provider(conn, &provider_id_clone)
})
.await;
match result {
Some(Ok(true)) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_provider_removed",
"route_provider",
provider_id,
None,
);
super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "ok": true }))
}
Some(Ok(false)) => err_json(StatusCode::NOT_FOUND, "route provider not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to remove route provider",
),
}
}
@@ -0,0 +1,179 @@
pub use crate::admin::db::{ReorderOutcome, RoutePatch, RouteProviderRow, RouteRow};
use crate::admin::state::SharedState;
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
#[derive(serde::Serialize)]
pub(crate) struct RouteResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub strategy: String,
pub rpm: Option<u32>,
pub tpm: Option<u64>,
pub budget_usd: Option<f64>,
pub enabled: bool,
pub guardrail_mode: Option<String>,
pub pxpipe_compress: Option<bool>,
pub pxpipe_models: Option<String>,
pub redact_secrets: Option<bool>,
pub position: i32,
pub provider_count: usize,
pub created_at: String,
pub updated_at: String,
}
#[derive(serde::Serialize)]
pub(crate) struct RouteProviderResponse {
pub id: String,
pub route_id: String,
pub backend_id: String,
pub backend_name: String,
pub provider_id: String,
pub models: Vec<String>,
pub priority: i32,
pub enabled: bool,
}
#[derive(Deserialize)]
pub(crate) struct CreateRouteRequest {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_strategy")]
pub strategy: String,
pub rpm: Option<u32>,
pub tpm: Option<u64>,
pub budget_usd: Option<f64>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub guardrail_mode: Option<String>,
#[serde(default)]
pub pxpipe_compress: Option<bool>,
#[serde(default)]
pub pxpipe_models: Option<String>,
#[serde(default)]
pub redact_secrets: Option<bool>,
#[serde(default)]
pub position: i32,
}
fn default_strategy() -> String {
"failover".into()
}
#[derive(Deserialize)]
pub(crate) struct AddRouteProviderRequest {
pub backend_id: String,
#[serde(default = "default_models")]
pub models: Vec<String>,
#[serde(default)]
pub priority: i32,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_models() -> Vec<String> {
vec!["*".into()]
}
fn default_true() -> bool {
true
}
#[derive(Deserialize)]
pub(crate) struct UpdateRouteProviderRequest {
pub models: Option<Vec<String>>,
pub priority: Option<i32>,
pub enabled: Option<bool>,
}
#[derive(Deserialize)]
pub(crate) struct ReorderRouteProvidersRequest {
pub provider_ids: Vec<String>,
}
pub(crate) fn route_to_response(route: &RouteRow, provider_count: usize) -> RouteResponse {
RouteResponse {
id: route.id.clone(),
name: route.name.clone(),
description: route.description.clone(),
strategy: route.strategy.clone(),
rpm: route.rpm,
tpm: route.tpm,
budget_usd: route.budget_usd,
enabled: route.enabled,
guardrail_mode: route.guardrail_mode.clone(),
pxpipe_compress: route.pxpipe_compress,
pxpipe_models: route.pxpipe_models.clone(),
redact_secrets: route.redact_secrets,
position: route.position,
provider_count,
created_at: route.created_at.clone(),
updated_at: route.updated_at.clone(),
}
}
pub(crate) fn db_error_status(e: &rusqlite::Error) -> StatusCode {
if let rusqlite::Error::SqliteFailure(ref err, _) = e {
if err.code == rusqlite::ErrorCode::ConstraintViolation {
return StatusCode::CONFLICT;
}
}
StatusCode::INTERNAL_SERVER_ERROR
}
pub(crate) fn ok_json<T: serde::Serialize>(val: T) -> axum::response::Response {
(StatusCode::OK, Json(serde_json::to_value(val).unwrap())).into_response()
}
pub(crate) fn err_json(status: StatusCode, msg: impl Into<String>) -> axum::response::Response {
(status, Json(serde_json::json!({ "error": msg.into() }))).into_response()
}
pub(crate) fn build_provider_responses(
providers: &[RouteProviderRow],
backends: &[crate::admin::db::ManagedBackendRow],
) -> Vec<RouteProviderResponse> {
let backend_map: std::collections::HashMap<&str, &crate::admin::db::ManagedBackendRow> =
backends.iter().map(|b| (b.id.as_str(), b)).collect();
providers
.iter()
.map(|p| {
let backend = backend_map.get(p.backend_id.as_str());
RouteProviderResponse {
id: p.id.clone(),
route_id: p.route_id.clone(),
backend_id: p.backend_id.clone(),
backend_name: backend.map(|b| b.name.clone()).unwrap_or_default(),
provider_id: backend.map(|b| b.provider_id.clone()).unwrap_or_default(),
models: p.models.clone(),
priority: p.priority,
enabled: p.enabled,
}
})
.collect()
}
pub(crate) fn audit(
shared: &SharedState,
source_ip: Option<String>,
action: &str,
target_type: &str,
target_id: String,
detail: Option<String>,
) {
super::super::emit_audit(
shared,
crate::admin::db::AuditEntry {
id: None,
timestamp: None,
action: action.into(),
target_type: target_type.into(),
target_id: Some(target_id),
detail,
source_ip,
},
);
}
@@ -0,0 +1,9 @@
pub(crate) mod helpers;
pub(crate) mod providers;
pub(crate) mod routes;
pub(crate) use providers::{
add_route_provider_handler, list_route_providers_handler, remove_route_provider_handler,
reorder_route_providers_handler, update_route_provider_handler,
};
pub(crate) use routes::{create_route, delete_route, list_routes, update_route};
@@ -0,0 +1,233 @@
use crate::admin::state::SharedState;
use axum::{
extract::{ConnectInfo, Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use std::net::SocketAddr;
use super::helpers::{
audit, build_provider_responses, db_error_status, err_json, ok_json, AddRouteProviderRequest,
ReorderOutcome, ReorderRouteProvidersRequest, UpdateRouteProviderRequest,
};
pub(crate) async fn list_route_providers_handler(
State(shared): State<SharedState>,
Path(route_id): Path<String>,
) -> axum::response::Response {
let result = crate::admin::state::with_db(&shared.db, move |conn| {
if crate::admin::db::get_route(conn, &route_id)
.ok()
.flatten()
.is_none()
{
return Ok::<_, rusqlite::Error>(None);
}
let providers = crate::admin::db::list_route_providers(conn, &route_id)?;
let backends = crate::admin::db::list_managed_backends(conn).unwrap_or_default();
Ok(Some(build_provider_responses(&providers, &backends)))
})
.await;
match result {
Some(Ok(Some(resp))) => ok_json(serde_json::json!({ "providers": resp })),
Some(Ok(None)) => err_json(StatusCode::NOT_FOUND, "route not found"),
_ => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to list route providers",
),
}
}
pub(crate) async fn add_route_provider_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(route_id): Path<String>,
Json(body): Json<AddRouteProviderRequest>,
) -> axum::response::Response {
let route_id_clone = route_id.clone();
let backend_id = body.backend_id.clone();
let models = body.models.clone();
let priority = body.priority;
let enabled = body.enabled;
let backend_id_for_db = backend_id.clone();
let backend_id_check = backend_id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
if crate::admin::db::get_route(conn, &route_id_clone)
.ok()
.flatten()
.is_none()
{
return Ok::<_, rusqlite::Error>(Err::<(), String>("route not found".into()));
}
if !crate::admin::db::managed_backend_exists(conn, &backend_id_check)? {
return Ok(Err::<(), String>("backend not found".into()));
}
crate::admin::db::add_route_provider(
conn,
&route_id_clone,
&backend_id_for_db,
&models,
priority,
enabled,
)?;
Ok(Ok::<(), String>(()))
})
.await;
match result {
Some(Ok(Ok(()))) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_provider_added",
"route_provider",
route_id,
Some(format!("backend_id={}", backend_id)),
);
super::super::rebuild_route_router(&shared).await;
(StatusCode::CREATED, Json(serde_json::json!({ "ok": true }))).into_response()
}
Some(Ok(Err(msg))) => err_json(
if msg == "route not found" {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_REQUEST
},
msg,
),
Some(Err(e)) => err_json(db_error_status(&e), e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to add route provider",
),
}
}
pub(crate) async fn update_route_provider_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path((route_id, provider_id)): Path<(String, String)>,
Json(body): Json<UpdateRouteProviderRequest>,
) -> axum::response::Response {
let provider_id_clone = provider_id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::update_route_provider(
conn,
&provider_id_clone,
body.models.as_deref(),
body.priority,
body.enabled,
)
})
.await;
match result {
Some(Ok(true)) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_provider_updated",
"route_provider",
provider_id,
Some(format!("route_id={}", route_id)),
);
super::super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "ok": true }))
}
Some(Ok(false)) => err_json(StatusCode::NOT_FOUND, "route provider not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to update route provider",
),
}
}
pub(crate) async fn reorder_route_providers_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(route_id): Path<String>,
Json(body): Json<ReorderRouteProvidersRequest>,
) -> axum::response::Response {
let route_id_clone = route_id.clone();
let ordered = body.provider_ids.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
if crate::admin::db::get_route(conn, &route_id_clone)
.ok()
.flatten()
.is_none()
{
return Ok::<_, rusqlite::Error>(None);
}
let outcome = crate::admin::db::reorder_route_providers(conn, &route_id_clone, &ordered)?;
match outcome {
ReorderOutcome::Mismatch => Ok(Some(Err::<_, ()>(()))),
ReorderOutcome::Ok(rows) => {
let backends = crate::admin::db::list_managed_backends(conn).unwrap_or_default();
Ok(Some(Ok(build_provider_responses(&rows, &backends))))
}
}
})
.await;
match result {
Some(Ok(Some(Ok(providers)))) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_providers_reordered",
"route",
route_id,
Some(format!("count={}", providers.len())),
);
super::super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "providers": providers }))
}
Some(Ok(Some(Err(())))) => err_json(
StatusCode::BAD_REQUEST,
"provider_ids must match the route's current provider set exactly",
),
Some(Ok(None)) => err_json(StatusCode::NOT_FOUND, "route not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to reorder route providers",
),
}
}
pub(crate) async fn remove_route_provider_handler(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path((_route_id, provider_id)): Path<(String, String)>,
) -> axum::response::Response {
let provider_id_clone = provider_id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::remove_route_provider(conn, &provider_id_clone)
})
.await;
match result {
Some(Ok(true)) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_provider_removed",
"route_provider",
provider_id,
None,
);
super::super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "ok": true }))
}
Some(Ok(false)) => err_json(StatusCode::NOT_FOUND, "route provider not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to remove route provider",
),
}
}
@@ -0,0 +1,158 @@
use crate::admin::state::SharedState;
use axum::{
extract::{ConnectInfo, Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use std::net::SocketAddr;
use super::helpers::{
audit, db_error_status, err_json, ok_json, route_to_response, CreateRouteRequest, RoutePatch,
RouteRow,
};
pub(crate) async fn list_routes(State(shared): State<SharedState>) -> axum::response::Response {
let result = crate::admin::state::with_db(&shared.db, |conn| {
let routes = crate::admin::db::list_routes(conn)?;
let mut resp = Vec::with_capacity(routes.len());
for r in &routes {
let count = crate::admin::db::count_route_providers(conn, &r.id).unwrap_or(0);
resp.push(route_to_response(r, count));
}
Ok::<_, rusqlite::Error>(resp)
})
.await;
match result {
Some(Ok(resp)) => ok_json(serde_json::json!({ "routes": resp })),
_ => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to list routes"),
}
}
pub(crate) async fn create_route(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Json(body): Json<CreateRouteRequest>,
) -> axum::response::Response {
if body.name.trim().is_empty() {
return err_json(StatusCode::BAD_REQUEST, "name is required");
}
let now = crate::admin::db::now_iso8601();
let row = RouteRow {
id: uuid::Uuid::new_v4().to_string(),
name: body.name.trim().to_string(),
description: body.description,
strategy: body.strategy,
rpm: body.rpm,
tpm: body.tpm,
budget_usd: body.budget_usd,
enabled: body.enabled,
guardrail_mode: body.guardrail_mode,
pxpipe_compress: body.pxpipe_compress,
pxpipe_models: body.pxpipe_models,
redact_secrets: body.redact_secrets,
position: body.position,
created_at: now.clone(),
updated_at: now,
};
let row_clone = row.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::insert_route(conn, &row_clone)
})
.await;
match result {
Some(Ok(())) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_created",
"route",
row.name.clone(),
None,
);
super::super::rebuild_route_router(&shared).await;
(
StatusCode::CREATED,
Json(serde_json::to_value(route_to_response(&row, 0)).unwrap()),
)
.into_response()
}
Some(Err(e)) => err_json(db_error_status(&e), e.to_string()),
None => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create route"),
}
}
pub(crate) async fn update_route(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(id): Path<String>,
Json(body): Json<RoutePatch>,
) -> axum::response::Response {
let id_clone = id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
let updated = crate::admin::db::update_route(conn, &id_clone, &body)?;
if !updated {
return Ok::<_, rusqlite::Error>((false, None, 0));
}
let route = crate::admin::db::get_route(conn, &id_clone).ok().flatten();
let count = crate::admin::db::count_route_providers(conn, &id_clone).unwrap_or(0);
Ok((true, route, count))
})
.await;
match result {
Some(Ok((true, Some(r), count))) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_updated",
"route",
id,
None,
);
super::super::rebuild_route_router(&shared).await;
ok_json(route_to_response(&r, count))
}
Some(Ok((true, None, _))) => err_json(
StatusCode::INTERNAL_SERVER_ERROR,
"route not found after update",
),
Some(Ok((false, _, _))) => err_json(StatusCode::NOT_FOUND, "route not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to update route"),
}
}
pub(crate) async fn delete_route(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(shared): State<SharedState>,
Path(id): Path<String>,
) -> axum::response::Response {
let id_clone = id.clone();
let result = crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::delete_route(conn, &id_clone)
})
.await;
match result {
Some(Ok(true)) => {
audit(
&shared,
Some(addr.ip().to_string()),
"route_deleted",
"route",
id,
None,
);
super::super::rebuild_route_router(&shared).await;
ok_json(serde_json::json!({ "ok": true }))
}
Some(Ok(false)) => err_json(StatusCode::NOT_FOUND, "route not found"),
Some(Err(e)) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
None => err_json(StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete route"),
}
}
+166
View File
@@ -0,0 +1,166 @@
use super::types::LiteLLMParams;
use crate::config::single::validate_gcp_identifier;
use crate::config::{resolve_env_value, strip_v1_suffix, BackendKind};
/// Parse LiteLLM's "provider/model_name" format.
/// No prefix defaults to OpenAI (matches LiteLLM behavior).
/// Returns (kind, model_name, stub_provider) where stub_provider is set for
/// registry-resolved OpenAI-compatible providers so callers can use their default URL.
pub(super) fn parse_provider_model(
model: &str,
) -> (
BackendKind,
String,
Option<&'static anyllm_providers::ProviderDef>,
) {
let (provider, model_name) = model.split_once('/').unwrap_or(("openai", model));
let mut stub_provider: Option<&'static anyllm_providers::ProviderDef> = None;
let kind = match provider.to_ascii_lowercase().as_str() {
"openai" => BackendKind::OpenAI,
"azure" => BackendKind::AzureOpenAI,
"vertex_ai" | "vertex" => BackendKind::Vertex,
"gemini" => BackendKind::Gemini,
"anthropic" => {
stub_provider = anyllm_providers::get_provider("anthropic");
BackendKind::Anthropic
}
"bedrock" => BackendKind::Bedrock,
other => {
let prefix_with_slash = format!("{other}/");
if let Some(p) = anyllm_providers::find_by_litellm_prefix(&prefix_with_slash) {
let resolved = match anyllm_providers::resolve_backend(p.id) {
Some(("openai", _)) => {
stub_provider = Some(p);
BackendKind::OpenAI
}
Some(("anthropic", _)) => BackendKind::Anthropic,
Some(("gemini", _)) => BackendKind::Gemini,
Some(("vertex", _)) => BackendKind::Vertex,
Some(("azure", _)) => BackendKind::AzureOpenAI,
Some(("bedrock", _)) => BackendKind::Bedrock,
_ => {
tracing::warn!(provider = %other, "provider found in registry but protocol not mappable, treating as openai-compatible");
stub_provider = Some(p);
BackendKind::OpenAI
}
};
resolved
} else {
tracing::warn!(
provider = %other,
"unknown LiteLLM provider, treating as openai-compatible"
);
BackendKind::OpenAI
}
}
};
(kind, model_name.to_string(), stub_provider)
}
pub(super) fn provider_id_for_litellm_model(
model: &str,
kind: &BackendKind,
stub_provider: Option<&'static anyllm_providers::ProviderDef>,
) -> String {
if let Some(provider) = stub_provider {
return provider.id.to_string();
}
let raw_provider = model
.split_once('/')
.map(|(provider, _)| provider)
.unwrap_or("openai")
.to_ascii_lowercase();
match kind {
BackendKind::OpenAI => raw_provider,
BackendKind::AzureOpenAI => "azure".to_string(),
BackendKind::Vertex => "vertex_ai".to_string(),
BackendKind::Gemini => "gemini".to_string(),
BackendKind::Anthropic => "anthropic".to_string(),
BackendKind::Bedrock => "bedrock".to_string(),
}
}
/// Determine the base URL for a deployment, applying provider-specific defaults.
pub(super) fn resolve_base_url(
kind: &BackendKind,
params: &LiteLLMParams,
stub_provider: Option<&'static anyllm_providers::ProviderDef>,
actual_model: &str,
) -> String {
if let Some(ref url) = params.api_base {
let resolved =
resolve_env_value(url).unwrap_or_else(|e| panic!("model_list api_base: {e}"));
if *kind == BackendKind::AzureOpenAI {
let api_version = params.api_version.as_deref().unwrap_or("2024-10-21");
if !resolved.contains("/openai/deployments/") {
let deployment = azure_deployment_from_model(actual_model);
return format!(
"{}/openai/deployments/{deployment}/chat/completions?api-version={api_version}",
resolved.trim_end_matches('/'),
);
}
if !resolved.contains("api-version=") {
let sep = if resolved.contains('?') { '&' } else { '?' };
return format!("{resolved}{sep}api-version={api_version}");
}
return resolved;
}
return resolved;
}
match kind {
BackendKind::OpenAI => {
let url = if let Some(provider) = stub_provider {
if provider.default_base_url.is_empty() {
panic!(
"model_list provider '{}' requires api_base because it has no safe global API base URL",
provider.id
);
}
provider.default_base_url
} else {
"https://api.openai.com"
};
strip_v1_suffix(url).to_string()
}
BackendKind::Gemini => {
"https://generativelanguage.googleapis.com/v1beta/openai".to_string()
}
BackendKind::Anthropic => std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string()),
BackendKind::Bedrock => params
.aws_region_name
.as_deref()
.map(|v| v.to_string())
.or_else(|| std::env::var("AWS_REGION").ok())
.unwrap_or_else(|| "us-east-1".to_string()),
BackendKind::AzureOpenAI => {
panic!("api_base is required for azure deployments in model_list")
}
BackendKind::Vertex => {
let project = params.vertex_project.as_deref().unwrap_or_else(|| {
panic!("vertex_project is required for vertex deployments in model_list")
});
let location = params.vertex_location.as_deref().unwrap_or_else(|| {
panic!("vertex_location is required for vertex deployments in model_list")
});
validate_gcp_identifier("vertex_project", project);
validate_gcp_identifier("vertex_location", location);
format!(
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi"
)
}
}
}
pub(super) fn azure_deployment_from_model(model: &str) -> &str {
for marker in ["o_series/", "gpt5_series/"] {
if let Some(deployment) = model.strip_prefix(marker) {
if !deployment.is_empty() {
return deployment;
}
}
}
model
}
@@ -1,696 +0,0 @@
use anyllm_proxy::admin;
use anyllm_proxy::config;
use anyllm_proxy::server::state as server_state;
use std::path::Path;
use std::sync::Arc;
#[allow(clippy::too_many_lines)]
pub(crate) async fn init_admin(
args: &[String],
data_dir: &Path,
multi_config: &config::MultiConfig,
model_router: Option<Arc<std::sync::RwLock<config::model_router::ModelRouter>>>,
tool_engine_state: &Option<Arc<server_state::ToolEngineState>>,
reload_handle: tracing_subscriber::reload::Handle<
tracing_subscriber::EnvFilter,
tracing_subscriber::Registry,
>,
) -> Option<(
admin::state::SharedState,
axum::Router,
tokio::net::TcpListener,
u16,
// Plaintext admin token, returned so the caller can print a ready-to-click
// tokenized URL on loopback binds. Dropped right after the startup banner.
String,
)> {
let flag_set = args.iter().any(|a| a == "--webui" || a == "--admin");
let force_disabled = matches!(
std::env::var("DISABLE_ADMIN").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
);
let enable_admin = flag_set && !force_disabled;
if !enable_admin {
return None;
}
let provider_catalog = Arc::new(anyllm_providers::ProviderCatalog::bundled());
let admin_port: u16 = match std::env::var("ADMIN_PORT") {
Ok(val) => val
.parse::<u16>()
.unwrap_or_else(|_| panic!("ADMIN_PORT must be a number in 1-65535, got '{val}'")),
Err(_) => 3001,
};
if admin_port == 0 {
panic!("ADMIN_PORT cannot be 0");
}
if admin_port < 1024 {
tracing::warn!(
port = admin_port,
"ADMIN_PORT is in the privileged range (< 1024); binding may fail without elevated privileges"
);
}
let admin_bind = {
let raw = std::env::var("ADMIN_BIND").unwrap_or_else(|_| "127.0.0.1".into());
// Translate well-known loopback aliases to explicit IPs before validation.
match raw.to_ascii_lowercase().as_str() {
"localhost" => "127.0.0.1".to_string(),
"localhost6" | "ip6-localhost" | "ip6-loopback" => "::1".to_string(),
_ => raw,
}
};
// Bind addresses must be explicit IPs. Hostnames other than the loopback
// aliases above are rejected: a bind address maps to a specific local
// interface and must be unambiguous.
if admin_bind.parse::<std::net::IpAddr>().is_err() {
panic!(
"ADMIN_BIND must be an IP address (e.g. 127.0.0.1 or 0.0.0.0), not a hostname — got '{}'",
std::env::var("ADMIN_BIND").unwrap_or_default()
);
}
if admin_port == multi_config.listen_port {
panic!(
"ADMIN_PORT ({admin_port}) must differ from LISTEN_PORT ({})",
multi_config.listen_port
);
}
let db_path = crate::main_helpers::bootstrap::resolve_db_path(data_dir);
let conn =
rusqlite::Connection::open(&db_path).expect("failed to open SQLite database for admin");
admin::db::init_db(&conn).expect("failed to initialize admin database schema");
let hmac_secret = Arc::new(admin::db::ensure_hmac_secret(&conn));
// Build initial RuntimeConfig from the loaded multi_config.
let mut model_mappings = indexmap::IndexMap::new();
for (name, bc) in &multi_config.backends {
model_mappings.insert(name.clone(), bc.model_mapping.clone());
}
let log_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
// The runtime default tracks the static per-process guardrail preset
// (built from YAML/env at startup, see `ToolEngineState.guardrails`) so
// an admin "reset to default" restores that mode rather than always
// falling back to disabled.
let tool_guardrail_default = tool_engine_state
.as_ref()
.map(|engine| engine.guardrails.mode.as_str().to_string())
.unwrap_or_else(|| {
anyllm_proxy::tools::ToolGuardrailMode::Disabled
.as_str()
.to_string()
});
let mut runtime_config = admin::state::RuntimeConfig {
model_mappings,
log_level,
log_bodies: multi_config.log_bodies,
redact_secrets: multi_config.redact_secrets,
anthropic_thinking_repair: multi_config.anthropic_thinking_repair,
pxpipe_compress: multi_config.pxpipe_compress,
pxpipe_models: anyllm_proxy::pxpipe::resolve_default_models_csv(),
rtk_compress: anyllm_proxy::rtk::resolve_default_enabled(),
rtk_models: anyllm_proxy::rtk::resolve_default_models_csv(),
forward_client_auth: multi_config.forward_client_auth,
tool_guardrail_mode: tool_guardrail_default.clone(),
optimizer_mode: anyllm_proxy::optimizer::resolve_default_mode()
.as_str()
.to_string(),
};
let runtime_defaults = admin::state::RuntimeConfigDefaults {
log_bodies: multi_config.log_bodies,
redact_secrets: multi_config.redact_secrets,
anthropic_thinking_repair: multi_config.anthropic_thinking_repair,
pxpipe_compress: multi_config.pxpipe_compress,
pxpipe_models: anyllm_proxy::pxpipe::resolve_default_models_csv(),
rtk_compress: anyllm_proxy::rtk::resolve_default_enabled(),
rtk_models: anyllm_proxy::rtk::resolve_default_models_csv(),
forward_client_auth: multi_config.forward_client_auth,
tool_guardrail_mode: tool_guardrail_default,
optimizer_mode: anyllm_proxy::optimizer::resolve_default_mode()
.as_str()
.to_string(),
};
let mut log_bodies_enabled_by_override = false;
let mut redact_secrets_enabled_by_override = false;
// Apply config overrides from SQLite (survive restarts).
if let Ok(overrides) = admin::db::get_config_overrides(&conn) {
for (key, value, _) in &overrides {
match key.as_str() {
"log_level" => {
// Apply the same allowlist enforced by the admin API to
// prevent a tampered SQLite database from enabling trace-level
// logging, which would expose API keys in HTTP headers.
const ALLOWED_LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug"];
let normalized = value.trim().to_lowercase();
if ALLOWED_LOG_LEVELS.contains(&normalized.as_str()) {
runtime_config.log_level = normalized;
} else {
tracing::warn!(
value = %value,
"ignoring invalid log_level override from database"
);
}
}
"log_bodies" => {
runtime_config.log_bodies = value == "true";
log_bodies_enabled_by_override =
runtime_config.log_bodies && !multi_config.log_bodies;
}
"redact_secrets" => {
runtime_config.redact_secrets = value == "true";
redact_secrets_enabled_by_override =
runtime_config.redact_secrets && !multi_config.redact_secrets;
}
"anthropic_thinking_repair" => {
runtime_config.anthropic_thinking_repair = value == "true";
}
"pxpipe_compress" => {
runtime_config.pxpipe_compress = value == "true";
}
"pxpipe_models" => {
runtime_config.pxpipe_models = value.clone();
}
"rtk_compress" => {
runtime_config.rtk_compress = value == "true";
}
"rtk_models" => {
runtime_config.rtk_models = value.clone();
}
"forward_client_auth" => {
// Defensively re-validate against the same rule
// enforced by put_config (a tampered/hand-edited SQLite
// row could otherwise re-enable a misconfigured toggle
// that would let multiple distinct PROXY_API_KEYS
// entries each redirect the upstream Anthropic
// credential -- see
// server/middleware/auth.rs::forward_client_auth_misconfigured).
let wants_enabled = value == "true";
if wants_enabled
&& anyllm_proxy::server::middleware::forward_client_auth_misconfigured(
anyllm_proxy::server::middleware::distinct_static_key_count(),
anyllm_proxy::server::middleware::open_relay_active(),
)
{
tracing::warn!(
"ignoring persisted forward_client_auth=true override: 2+ \
PROXY_API_KEYS entries with no PROXY_OPEN_RELAY would let \
different callers each redirect the upstream Anthropic credential"
);
} else {
runtime_config.forward_client_auth = wants_enabled;
}
}
"tool_guardrail_mode" => {
if value
.parse::<anyllm_proxy::tools::ToolGuardrailMode>()
.is_ok()
{
runtime_config.tool_guardrail_mode = value.clone();
} else {
tracing::warn!(
value = %value,
"ignoring invalid tool_guardrail_mode override from database"
);
}
}
"optimizer_mode" => {
if value.parse::<anyllm_optimize_core::Mode>().is_ok() {
runtime_config.optimizer_mode = value.clone();
} else {
tracing::warn!(
value = %value,
"ignoring invalid optimizer_mode override from database"
);
}
}
k if k.ends_with(".big_model") => {
let backend = k.strip_suffix(".big_model").unwrap();
if let Some(m) = runtime_config.model_mappings.get_mut(backend) {
m.big_model = value.clone();
}
}
k if k.ends_with(".small_model") => {
let backend = k.strip_suffix(".small_model").unwrap();
if let Some(m) = runtime_config.model_mappings.get_mut(backend) {
m.small_model = value.clone();
}
}
_ => {
tracing::debug!(key = %key, "unknown config override, skipping");
}
}
}
if !overrides.is_empty() {
tracing::info!(
count = overrides.len(),
"applied config overrides from database"
);
}
}
if log_bodies_enabled_by_override {
tracing::warn!(
"persisted admin override enabled LOG_BODIES: request and response bodies will be \
logged at debug level. This may expose sensitive data (prompts, API keys, PII)."
);
}
if redact_secrets_enabled_by_override {
tracing::warn!(
"persisted admin override enabled REDACT_SECRETS: upstream JSON/text request payloads \
will be scanned and detected secrets will be replaced before forwarding."
);
}
if let Err(message) =
anyllm_proxy::server::ensure_secret_redaction_available(runtime_config.redact_secrets)
{
panic!("{}", message);
}
let runtime_config = Arc::new(std::sync::RwLock::new(runtime_config));
// Build the log_reload closure that captures the reload handle.
// We get this reload handle from caller or trace initialization.
// Note: reload_handle is captured dynamically during async_main setup,
// so we can resolve this by passing reload_handle, or keeping the logic
// in main mod.rs. Let's pass the log_reload closure or reload_handle in.
// Actually, let's define reload_handle as a parameter to init_admin, or pass
// log_reload in.
// Let's pass:
// log_reload: Arc<dyn Fn(&str) -> bool + Send + Sync>
// Load active virtual keys from SQLite into in-memory DashMap.
let virtual_keys = Arc::new(dashmap::DashMap::new());
{
if let Ok(active_keys) = admin::db::load_active_virtual_keys(&conn) {
for key_row in &active_keys {
if let Some(hash_bytes) = admin::keys::hash_from_hex(&key_row.key_hash) {
virtual_keys.insert(
hash_bytes,
admin::keys::VirtualKeyMeta {
id: key_row.id,
description: key_row.description.clone(),
expires_at: key_row.expires_at.as_deref().and_then(|s| {
anyllm_proxy::integrations::langfuse::iso8601_to_epoch(s)
.and_then(|e| i64::try_from(e).ok())
}),
rpm_limit: key_row.rpm_limit,
tpm_limit: key_row.tpm_limit,
rate_state: Arc::new(admin::keys::RateLimitState::new()),
role: admin::keys::KeyRole::from_str_or_default(&key_row.role),
max_budget_usd: key_row.max_budget_usd,
budget_duration: key_row
.budget_duration
.as_deref()
.and_then(admin::keys::BudgetDuration::parse),
period_start: key_row.period_start.clone(),
period_spend_usd: key_row.period_spend_usd,
allowed_models: key_row.allowed_models.clone(),
allowed_routes: key_row.allowed_routes.clone(),
},
);
}
}
tracing::info!(
count = active_keys.len(),
"loaded virtual API keys from database"
);
}
}
// Make virtual keys and HMAC secret available to the auth middleware.
anyllm_proxy::server::middleware::set_virtual_keys(virtual_keys.clone());
anyllm_proxy::server::middleware::set_hmac_secret(hmac_secret.clone());
let virtual_keys_pruner = virtual_keys.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
interval.tick().await;
let now = anyllm_proxy::admin::keys::now_ms();
// Single pass: slide rate-limit windows forward (frees old
// buckets) and drop expired keys. Without active eviction,
// expired keys accumulate in the DashMap until next auth use.
let now_secs = (now / 1000) as i64;
virtual_keys_pruner.retain(|_, v| {
let _ = v.rate_state.check_rpm(0, now);
let _ = v.rate_state.check_tpm(0, now);
v.expires_at.is_none_or(|exp| now_secs < exp)
});
}
});
// Load managed backends from SQLite into in-memory HashMap.
let managed_backends = {
let mut map = std::collections::HashMap::new();
if let Ok(rows) = admin::db::list_managed_backends(&conn) {
for row in rows {
match provider_catalog.get_provider(&row.provider_id) {
None => {
tracing::warn!(
provider_id = %row.provider_id,
backend_id = %row.id,
"managed backend references unknown provider; skipping"
);
}
Some(provider) => {
match anyllm_proxy::admin::routes::managed_backends::row_to_backend_config(
&row, provider,
) {
Err(e) => {
tracing::warn!(
provider_id = %row.provider_id,
backend_id = %row.id,
error = %e.message(),
"managed backend configuration is invalid; skipping"
);
}
Ok(bc) => {
let client =
anyllm_proxy::backend::BackendClient::from_backend_config(&bc);
map.insert(row.name.clone(), (row, client));
}
}
}
}
}
}
tracing::info!(count = map.len(), "loaded managed backends from SQLite");
Arc::new(std::sync::RwLock::new(map))
};
let db = Arc::new(std::sync::Mutex::new(conn));
// Compile the initial route dispatch table from the admin DB (enabled routes
// + providers + backends). Rebuilt on route/backend CRUD via rebuild_route_router.
let route_router = {
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
let rr = config::route_router::RouteRouter::build_from_db(&conn).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to build route router from DB; starting empty");
config::route_router::RouteRouter::empty()
});
tracing::info!(
has_routes = !rr.is_empty(),
"initialized route dispatch table"
);
Some(Arc::new(std::sync::RwLock::new(rr)))
};
let (events_tx, _) = tokio::sync::broadcast::channel(1024);
let log_tx = admin::db::spawn_write_buffer(db.clone());
let backend_metrics: std::collections::HashMap<String, anyllm_proxy::metrics::Metrics> =
std::collections::HashMap::new();
let log_reload: Arc<dyn Fn(&str) -> bool + Send + Sync> = {
let handle = reload_handle;
Arc::new(
move |new_filter: &str| match tracing_subscriber::EnvFilter::try_new(new_filter) {
Ok(f) => handle.reload(f).is_ok(),
Err(e) => {
tracing::error!(filter = new_filter, error = %e, "invalid log filter string");
false
}
},
)
};
// Admin token: use env var or generate 256-bit random hex written to a file.
let admin_token = match std::env::var("ADMIN_TOKEN") {
Ok(t) => {
if t.len() < 32 {
tracing::warn!(
len = t.len(),
"ADMIN_TOKEN is shorter than 32 characters; use a longer random value to reduce brute-force risk (generate one with: openssl rand -hex 32)"
);
}
t
}
Err(_) => {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).expect("getrandom failed");
let token = hex::encode(buf);
let token_path = crate::main_helpers::bootstrap::resolve_admin_token_path(data_dir);
let token_path_str = token_path.to_string_lossy().to_string();
// Write token to file with restrictive permissions instead of stderr
if let Err(e) =
crate::main_helpers::bootstrap::write_token_file(&token_path_str, &token)
{
panic!(
"Cannot write admin token to {token_path_str}: {e}. Set ADMIN_TOKEN env var explicitly or ensure the path is writable."
);
} else {
tracing::info!(
path = %token_path_str,
"admin token written to {token_path_str}; set ADMIN_TOKEN for a fixed token across restarts"
);
}
token
}
};
// Keep a plaintext copy to return for the loopback startup URL before the
// token is wrapped/zeroized and moved into the router.
let admin_token_plain = admin_token.clone();
let admin_token = Arc::new(zeroize::Zeroizing::new(admin_token));
let shared = admin::state::SharedState {
db: db.clone(),
events_tx: events_tx.clone(),
runtime_config: runtime_config.clone(),
runtime_defaults,
backend_metrics: Arc::new(backend_metrics),
log_tx,
log_reload: Some(log_reload),
config_write_lock: Arc::new(tokio::sync::Mutex::new(())),
virtual_keys,
hmac_secret,
model_router: model_router.clone(),
route_router,
provider_catalog: provider_catalog.clone(),
mcp_manager: tool_engine_state
.as_ref()
.and_then(|s| s.mcp_manager.clone()),
issued_csrf_tokens: Arc::new(
moka::sync::Cache::builder()
.max_capacity(1_000)
.time_to_live(std::time::Duration::from_secs(86400))
.build(),
),
started_at: std::time::SystemTime::now(),
listen_port: multi_config.listen_port,
managed_backends,
};
// Provider model cache auto-refresh (only when --webui is active).
let auto_refresh = matches!(
std::env::var("PROVIDER_AUTO_REFRESH").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
);
let refresh_interval_hours: u64 = std::env::var("PROVIDER_REFRESH_INTERVAL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(168);
if auto_refresh {
let shared_for_refresh = shared.clone();
let client = crate::main_helpers::providers_cmd::PROVIDER_REFRESH_CLIENT.clone();
tokio::spawn(async move {
let interval = std::time::Duration::from_secs(refresh_interval_hours * 3600);
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
loop {
let providers: Vec<_> = shared_for_refresh
.provider_catalog
.all_providers()
.cloned()
.collect();
for provider in providers {
if !provider.capabilities.chat_completions {
continue;
}
if provider.default_base_url.is_empty() {
continue;
}
let api_key = provider
.env_vars
.iter()
.find_map(|v| std::env::var(v.as_str()).ok());
if api_key.is_none() {
continue;
}
let url = format!(
"{}/v1/models",
provider.default_base_url.trim_end_matches('/')
);
let provider_id = provider.id.clone();
let mut req = client.get(&url);
if let Some(ref key) = api_key {
req = req.header("Authorization", format!("Bearer {key}"));
}
match req.send().await {
Err(e) => tracing::warn!(
provider = %provider_id,
error = %e,
"provider auto-refresh failed"
),
Ok(resp) if !resp.status().is_success() => tracing::warn!(
provider = %provider_id,
status = %resp.status(),
"provider auto-refresh upstream error"
),
Ok(resp) => match resp.json::<serde_json::Value>().await {
Err(e) => tracing::warn!(
provider = %provider_id,
error = %e,
"provider auto-refresh: invalid JSON response"
),
Ok(json) => {
let model_ids: Vec<String> = json
.get("data")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id")?.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let count = model_ids.len();
let db_ref = shared_for_refresh.db.clone();
let pid = provider_id.clone();
let _ = tokio::task::spawn_blocking(move || {
let mut conn_guard =
db_ref.lock().unwrap_or_else(|e| e.into_inner());
if let Err(e) = admin::db::upsert_provider_models_cache(
&mut conn_guard,
&pid,
&model_ids,
) {
tracing::warn!(
provider = %pid,
error = %e,
"failed to save auto-refresh results"
);
}
})
.await;
tracing::info!(
provider = %provider_id,
count = count,
"auto-refreshed provider model cache"
);
}
},
}
}
tokio::time::sleep(interval).await;
}
});
tracing::info!(
interval_hours = refresh_interval_hours,
"provider auto-refresh enabled"
);
}
// Spawn periodic tasks: log retention and metrics snapshot broadcast.
let retention_days: u32 = std::env::var("ADMIN_LOG_RETENTION_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(7);
let retention_db = shared.db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
interval.tick().await;
admin::state::with_db(
&retention_db,
move |conn_ref| match admin::db::purge_old_logs(conn_ref, retention_days) {
Ok(n) if n > 0 => {
tracing::info!(purged = n, "purged old request log entries")
}
Err(e) => tracing::error!(error = %e, "failed to purge old logs"),
_ => {}
},
)
.await;
}
});
// Periodic metrics snapshot broadcast (every 5 seconds) for WebSocket dashboard.
let snapshot_shared = shared.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
if snapshot_shared.events_tx.receiver_count() == 0 {
continue;
}
let mut aggregate = anyllm_proxy::metrics::MetricsSnapshot::default();
for m in snapshot_shared.backend_metrics.values() {
let snap = m.snapshot();
aggregate.requests_total += snap.requests_total;
aggregate.requests_error += snap.requests_error;
aggregate.requests_success += snap.requests_success;
aggregate.streams_started += snap.streams_started;
aggregate.streams_completed += snap.streams_completed;
aggregate.streams_failed += snap.streams_failed;
aggregate.streams_client_disconnected += snap.streams_client_disconnected;
}
let error_rate = aggregate.error_rate();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let since = now_secs.saturating_sub(60);
let rpm = admin::state::with_db(&snapshot_shared.db, move |conn_ref| {
admin::db::count_requests_since(conn_ref, since).unwrap_or(0)
})
.await
.unwrap_or(0) as f64;
let snapshot = admin::state::MetricsSnapshotData {
total_requests: aggregate.requests_total,
successful_requests: aggregate.requests_success,
failed_requests: aggregate.requests_error,
requests_per_minute: rpm,
p50_latency_ms: None,
p95_latency_ms: None,
error_rate,
streams_started: aggregate.streams_started,
streams_completed: aggregate.streams_completed,
streams_failed: aggregate.streams_failed,
streams_client_disconnected: aggregate.streams_client_disconnected,
};
let _ = snapshot_shared
.events_tx
.send(admin::state::AdminEvent::MetricsSnapshot(snapshot));
}
});
// Spawn periodic background health checker with a snapshot of backend base URLs.
let backend_urls: Vec<(String, String)> = multi_config
.backends
.iter()
.map(|(name, bc)| (name.clone(), bc.base_url.clone()))
.collect();
admin::health_check::spawn(shared.clone(), backend_urls);
// Bind admin listener
let admin_app = admin::routes::admin_router(shared.clone(), admin_token);
let admin_addr = format!("{admin_bind}:{admin_port}");
let admin_listener = tokio::net::TcpListener::bind(&admin_addr)
.await
.unwrap_or_else(|e| panic!("failed to bind admin to {admin_addr}: {e}"));
tracing::info!("admin listening on {admin_addr}");
Some((
shared,
admin_app,
admin_listener,
admin_port,
admin_token_plain,
))
}
@@ -0,0 +1,306 @@
use anyllm_proxy::admin;
use anyllm_proxy::config;
use anyllm_proxy::server::state as server_state;
use std::path::Path;
use std::sync::Arc;
pub(crate) fn load_runtime_config(
multi_config: &config::MultiConfig,
tool_engine_state: &Option<Arc<server_state::ToolEngineState>>,
) -> (
admin::state::RuntimeConfig,
admin::state::RuntimeConfigDefaults,
) {
let mut model_mappings = indexmap::IndexMap::new();
for (name, bc) in &multi_config.backends {
model_mappings.insert(name.clone(), bc.model_mapping.clone());
}
let log_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
let tool_guardrail_default = tool_engine_state
.as_ref()
.map(|engine| engine.guardrails.mode.as_str().to_string())
.unwrap_or_else(|| {
anyllm_proxy::tools::ToolGuardrailMode::Disabled
.as_str()
.to_string()
});
let runtime_config = admin::state::RuntimeConfig {
model_mappings,
log_level,
log_bodies: multi_config.log_bodies,
redact_secrets: multi_config.redact_secrets,
anthropic_thinking_repair: multi_config.anthropic_thinking_repair,
pxpipe_compress: multi_config.pxpipe_compress,
pxpipe_models: anyllm_proxy::pxpipe::resolve_default_models_csv(),
rtk_compress: anyllm_proxy::rtk::resolve_default_enabled(),
rtk_models: anyllm_proxy::rtk::resolve_default_models_csv(),
forward_client_auth: multi_config.forward_client_auth,
tool_guardrail_mode: tool_guardrail_default.clone(),
optimizer_mode: anyllm_proxy::optimizer::resolve_default_mode()
.as_str()
.to_string(),
};
let runtime_defaults = admin::state::RuntimeConfigDefaults {
log_bodies: multi_config.log_bodies,
redact_secrets: multi_config.redact_secrets,
anthropic_thinking_repair: multi_config.anthropic_thinking_repair,
pxpipe_compress: multi_config.pxpipe_compress,
pxpipe_models: anyllm_proxy::pxpipe::resolve_default_models_csv(),
rtk_compress: anyllm_proxy::rtk::resolve_default_enabled(),
rtk_models: anyllm_proxy::rtk::resolve_default_models_csv(),
forward_client_auth: multi_config.forward_client_auth,
tool_guardrail_mode: tool_guardrail_default,
optimizer_mode: anyllm_proxy::optimizer::resolve_default_mode()
.as_str()
.to_string(),
};
(runtime_config, runtime_defaults)
}
pub(crate) fn apply_config_overrides(
conn: &rusqlite::Connection,
runtime_config: &mut admin::state::RuntimeConfig,
multi_config: &config::MultiConfig,
) -> (bool, bool) {
let mut log_bodies_enabled_by_override = false;
let mut redact_secrets_enabled_by_override = false;
if let Ok(overrides) = admin::db::get_config_overrides(conn) {
for (key, value, _) in &overrides {
match key.as_str() {
"log_level" => {
const ALLOWED_LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug"];
let normalized = value.trim().to_lowercase();
if ALLOWED_LOG_LEVELS.contains(&normalized.as_str()) {
runtime_config.log_level = normalized;
} else {
tracing::warn!(
value = %value,
"ignoring invalid log_level override from database"
);
}
}
"log_bodies" => {
runtime_config.log_bodies = value == "true";
log_bodies_enabled_by_override =
runtime_config.log_bodies && !multi_config.log_bodies;
}
"redact_secrets" => {
runtime_config.redact_secrets = value == "true";
redact_secrets_enabled_by_override =
runtime_config.redact_secrets && !multi_config.redact_secrets;
}
"anthropic_thinking_repair" => {
runtime_config.anthropic_thinking_repair = value == "true";
}
"pxpipe_compress" => {
runtime_config.pxpipe_compress = value == "true";
}
"pxpipe_models" => {
runtime_config.pxpipe_models = value.clone();
}
"rtk_compress" => {
runtime_config.rtk_compress = value == "true";
}
"rtk_models" => {
runtime_config.rtk_models = value.clone();
}
"forward_client_auth" => {
let wants_enabled = value == "true";
if wants_enabled
&& anyllm_proxy::server::middleware::forward_client_auth_misconfigured(
anyllm_proxy::server::middleware::distinct_static_key_count(),
anyllm_proxy::server::middleware::open_relay_active(),
)
{
tracing::warn!(
"ignoring persisted forward_client_auth=true override: 2+ \
PROXY_API_KEYS entries with no PROXY_OPEN_RELAY would let \
different callers each redirect the upstream Anthropic credential"
);
} else {
runtime_config.forward_client_auth = wants_enabled;
}
}
"tool_guardrail_mode" => {
if value
.parse::<anyllm_proxy::tools::ToolGuardrailMode>()
.is_ok()
{
runtime_config.tool_guardrail_mode = value.clone();
} else {
tracing::warn!(
value = %value,
"ignoring invalid tool_guardrail_mode override from database"
);
}
}
"optimizer_mode" => {
if value.parse::<anyllm_optimize_core::Mode>().is_ok() {
runtime_config.optimizer_mode = value.clone();
} else {
tracing::warn!(
value = %value,
"ignoring invalid optimizer_mode override from database"
);
}
}
k if k.ends_with(".big_model") => {
let backend = k.strip_suffix(".big_model").unwrap();
if let Some(m) = runtime_config.model_mappings.get_mut(backend) {
m.big_model = value.clone();
}
}
k if k.ends_with(".small_model") => {
let backend = k.strip_suffix(".small_model").unwrap();
if let Some(m) = runtime_config.model_mappings.get_mut(backend) {
m.small_model = value.clone();
}
}
_ => {
tracing::debug!(key = %key, "unknown config override, skipping");
}
}
}
if !overrides.is_empty() {
tracing::info!(
count = overrides.len(),
"applied config overrides from database"
);
}
}
(
log_bodies_enabled_by_override,
redact_secrets_enabled_by_override,
)
}
pub(crate) fn load_virtual_keys(
conn: &rusqlite::Connection,
) -> Arc<dashmap::DashMap<[u8; 32], admin::keys::VirtualKeyMeta>> {
let virtual_keys = Arc::new(dashmap::DashMap::new());
if let Ok(active_keys) = admin::db::load_active_virtual_keys(conn) {
for key_row in &active_keys {
if let Some(hash_bytes) = admin::keys::hash_from_hex(&key_row.key_hash) {
virtual_keys.insert(
hash_bytes,
admin::keys::VirtualKeyMeta {
id: key_row.id,
description: key_row.description.clone(),
expires_at: key_row.expires_at.as_deref().and_then(|s| {
anyllm_proxy::integrations::langfuse::iso8601_to_epoch(s)
.and_then(|e| i64::try_from(e).ok())
}),
rpm_limit: key_row.rpm_limit,
tpm_limit: key_row.tpm_limit,
rate_state: Arc::new(admin::keys::RateLimitState::new()),
role: admin::keys::KeyRole::from_str_or_default(&key_row.role),
max_budget_usd: key_row.max_budget_usd,
budget_duration: key_row
.budget_duration
.as_deref()
.and_then(admin::keys::BudgetDuration::parse),
period_start: key_row.period_start.clone(),
period_spend_usd: key_row.period_spend_usd,
allowed_models: key_row.allowed_models.clone(),
allowed_routes: key_row.allowed_routes.clone(),
},
);
}
}
tracing::info!(
count = active_keys.len(),
"loaded virtual API keys from database"
);
}
virtual_keys
}
pub(crate) fn load_managed_backends(
conn: &rusqlite::Connection,
provider_catalog: &Arc<anyllm_providers::ProviderCatalog>,
) -> Arc<
std::sync::RwLock<
std::collections::HashMap<
String,
(
anyllm_proxy::admin::db::ManagedBackendRow,
anyllm_proxy::backend::BackendClient,
),
>,
>,
> {
let mut map = std::collections::HashMap::new();
if let Ok(rows) = admin::db::list_managed_backends(conn) {
for row in rows {
match provider_catalog.get_provider(&row.provider_id) {
None => {
tracing::warn!(
provider_id = %row.provider_id,
backend_id = %row.id,
"managed backend references unknown provider; skipping"
);
}
Some(provider) => {
match anyllm_proxy::admin::routes::managed_backends::row_to_backend_config(
&row, provider,
) {
Err(e) => {
tracing::warn!(
provider_id = %row.provider_id,
backend_id = %row.id,
error = %e.message(),
"managed backend configuration is invalid; skipping"
);
}
Ok(bc) => {
let client =
anyllm_proxy::backend::BackendClient::from_backend_config(&bc);
map.insert(row.name.clone(), (row, client));
}
}
}
}
}
}
tracing::info!(count = map.len(), "loaded managed backends from SQLite");
Arc::new(std::sync::RwLock::new(map))
}
pub(crate) fn resolve_admin_token(data_dir: &Path) -> (String, Arc<zeroize::Zeroizing<String>>) {
let admin_token = match std::env::var("ADMIN_TOKEN") {
Ok(t) => {
if t.len() < 32 {
tracing::warn!(
len = t.len(),
"ADMIN_TOKEN is shorter than 32 characters; use a longer random value to reduce brute-force risk (generate one with: openssl rand -hex 32)"
);
}
t
}
Err(_) => {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).expect("getrandom failed");
let token = hex::encode(buf);
let token_path = crate::main_helpers::bootstrap::resolve_admin_token_path(data_dir);
let token_path_str = token_path.to_string_lossy().to_string();
if let Err(e) =
crate::main_helpers::bootstrap::write_token_file(&token_path_str, &token)
{
panic!(
"Cannot write admin token to {token_path_str}: {e}. Set ADMIN_TOKEN env var explicitly or ensure the path is writable."
);
} else {
tracing::info!(
path = %token_path_str,
"admin token written to {token_path_str}; set ADMIN_TOKEN for a fixed token across restarts"
);
}
token
}
};
let admin_token_plain = admin_token.clone();
let admin_token_wrapped = Arc::new(zeroize::Zeroizing::new(admin_token));
(admin_token_plain, admin_token_wrapped)
}
@@ -0,0 +1,235 @@
pub(crate) mod config;
pub(crate) mod tasks;
use anyllm_proxy::admin;
use anyllm_proxy::config as proxy_config;
use anyllm_proxy::server::state as server_state;
use std::path::Path;
use std::sync::Arc;
#[allow(clippy::too_many_lines)]
pub(crate) async fn init_admin(
args: &[String],
data_dir: &Path,
multi_config: &proxy_config::MultiConfig,
model_router: Option<Arc<std::sync::RwLock<proxy_config::model_router::ModelRouter>>>,
tool_engine_state: &Option<Arc<server_state::ToolEngineState>>,
reload_handle: tracing_subscriber::reload::Handle<
tracing_subscriber::EnvFilter,
tracing_subscriber::Registry,
>,
) -> Option<(
admin::state::SharedState,
axum::Router,
tokio::net::TcpListener,
u16,
String,
)> {
let flag_set = args.iter().any(|a| a == "--webui" || a == "--admin");
let force_disabled = matches!(
std::env::var("DISABLE_ADMIN").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
);
let enable_admin = flag_set && !force_disabled;
if !enable_admin {
return None;
}
let provider_catalog = Arc::new(anyllm_providers::ProviderCatalog::bundled());
let admin_port: u16 = match std::env::var("ADMIN_PORT") {
Ok(val) => val
.parse::<u16>()
.unwrap_or_else(|_| panic!("ADMIN_PORT must be a number in 1-65535, got '{val}'")),
Err(_) => 3001,
};
if admin_port == 0 {
panic!("ADMIN_PORT cannot be 0");
}
if admin_port < 1024 {
tracing::warn!(
port = admin_port,
"ADMIN_PORT is in the privileged range (< 1024); binding may fail without elevated privileges"
);
}
let admin_bind = {
let raw = std::env::var("ADMIN_BIND").unwrap_or_else(|_| "127.0.0.1".into());
match raw.to_ascii_lowercase().as_str() {
"localhost" => "127.0.0.1".to_string(),
"localhost6" | "ip6-localhost" | "ip6-loopback" => "::1".to_string(),
_ => raw,
}
};
if admin_bind.parse::<std::net::IpAddr>().is_err() {
panic!(
"ADMIN_BIND must be an IP address (e.g. 127.0.0.1 or 0.0.0.0), not a hostname — got '{}'",
std::env::var("ADMIN_BIND").unwrap_or_default()
);
}
if admin_port == multi_config.listen_port {
panic!(
"ADMIN_PORT ({admin_port}) must differ from LISTEN_PORT ({})",
multi_config.listen_port
);
}
let db_path = crate::main_helpers::bootstrap::resolve_db_path(data_dir);
let conn =
rusqlite::Connection::open(&db_path).expect("failed to open SQLite database for admin");
admin::db::init_db(&conn).expect("failed to initialize admin database schema");
let hmac_secret = Arc::new(admin::db::ensure_hmac_secret(&conn));
// Load configuration and apply database overrides
let (mut runtime_config, runtime_defaults) =
config::load_runtime_config(multi_config, tool_engine_state);
let (log_bodies_override, redact_secrets_override) =
config::apply_config_overrides(&conn, &mut runtime_config, multi_config);
if log_bodies_override {
tracing::warn!(
"persisted admin override enabled LOG_BODIES: request and response bodies will be \
logged at debug level. This may expose sensitive data (prompts, API keys, PII)."
);
}
if redact_secrets_override {
tracing::warn!(
"persisted admin override enabled REDACT_SECRETS: upstream JSON/text request payloads \
will be scanned and detected secrets will be replaced before forwarding."
);
}
if let Err(message) =
anyllm_proxy::server::ensure_secret_redaction_available(runtime_config.redact_secrets)
{
panic!("{}", message);
}
let runtime_config = Arc::new(std::sync::RwLock::new(runtime_config));
// Load virtual API keys and setup rate-limit state
let virtual_keys = config::load_virtual_keys(&conn);
anyllm_proxy::server::middleware::set_virtual_keys(virtual_keys.clone());
anyllm_proxy::server::middleware::set_hmac_secret(hmac_secret.clone());
// Spawn key pruner background task
tasks::spawn_background_pruner(virtual_keys.clone());
// Load managed backends
let managed_backends = config::load_managed_backends(&conn, &provider_catalog);
let db = Arc::new(std::sync::Mutex::new(conn));
// Compile initial route dispatch table
let route_router = {
let conn_guard = db.lock().unwrap_or_else(|e| e.into_inner());
let rr = proxy_config::route_router::RouteRouter::build_from_db(&conn_guard)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to build route router from DB; starting empty");
proxy_config::route_router::RouteRouter::empty()
});
tracing::info!(
has_routes = !rr.is_empty(),
"initialized route dispatch table"
);
Some(Arc::new(std::sync::RwLock::new(rr)))
};
let (events_tx, _) = tokio::sync::broadcast::channel(1024);
let log_tx = admin::db::spawn_write_buffer(db.clone());
let backend_metrics = std::collections::HashMap::new();
let log_reload: Arc<dyn Fn(&str) -> bool + Send + Sync> = {
let handle = reload_handle;
Arc::new(
move |new_filter: &str| match tracing_subscriber::EnvFilter::try_new(new_filter) {
Ok(f) => handle.reload(f).is_ok(),
Err(e) => {
tracing::error!(filter = new_filter, error = %e, "invalid log filter string");
false
}
},
)
};
// Resolve admin token
let (admin_token_plain, admin_token) = config::resolve_admin_token(data_dir);
let shared = admin::state::SharedState {
db: db.clone(),
events_tx: events_tx.clone(),
runtime_config: runtime_config.clone(),
runtime_defaults,
backend_metrics: Arc::new(backend_metrics),
log_tx,
log_reload: Some(log_reload),
config_write_lock: Arc::new(tokio::sync::Mutex::new(())),
virtual_keys,
hmac_secret,
model_router: model_router.clone(),
route_router,
provider_catalog: provider_catalog.clone(),
mcp_manager: tool_engine_state
.as_ref()
.and_then(|s| s.mcp_manager.clone()),
issued_csrf_tokens: Arc::new(
moka::sync::Cache::builder()
.max_capacity(1_000)
.time_to_live(std::time::Duration::from_secs(86400))
.build(),
),
started_at: std::time::SystemTime::now(),
listen_port: multi_config.listen_port,
managed_backends,
};
// Provider model cache auto-refresh (only when --webui is active)
let auto_refresh = matches!(
std::env::var("PROVIDER_AUTO_REFRESH").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
);
let refresh_interval_hours: u64 = std::env::var("PROVIDER_REFRESH_INTERVAL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(168);
if auto_refresh {
tasks::spawn_auto_refresh_task(shared.clone(), refresh_interval_hours);
tracing::info!(
interval_hours = refresh_interval_hours,
"provider auto-refresh enabled"
);
}
// Periodic tasks: log retention and metrics snapshot
let retention_days: u32 = std::env::var("ADMIN_LOG_RETENTION_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(7);
tasks::spawn_periodic_tasks(shared.clone(), retention_days);
// Spawn periodic background health checker
let backend_urls: Vec<(String, String)> = multi_config
.backends
.iter()
.map(|(name, bc)| (name.clone(), bc.base_url.clone()))
.collect();
admin::health_check::spawn(shared.clone(), backend_urls);
// Bind admin listener
let admin_app = admin::routes::admin_router(shared.clone(), admin_token);
let admin_addr = format!("{admin_bind}:{admin_port}");
let admin_listener = tokio::net::TcpListener::bind(&admin_addr)
.await
.unwrap_or_else(|e| panic!("failed to bind admin to {admin_addr}: {e}"));
tracing::info!("admin listening on {admin_addr}");
Some((
shared,
admin_app,
admin_listener,
admin_port,
admin_token_plain,
))
}
@@ -0,0 +1,187 @@
use anyllm_proxy::admin;
use std::sync::Arc;
pub(crate) fn spawn_background_pruner(
virtual_keys: Arc<dashmap::DashMap<[u8; 32], admin::keys::VirtualKeyMeta>>,
) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
interval.tick().await;
let now = anyllm_proxy::admin::keys::now_ms();
let now_secs = (now / 1000) as i64;
virtual_keys.retain(|_, v| {
let _ = v.rate_state.check_rpm(0, now);
let _ = v.rate_state.check_tpm(0, now);
v.expires_at.is_none_or(|exp| now_secs < exp)
});
}
});
}
pub(crate) fn spawn_auto_refresh_task(
shared: admin::state::SharedState,
refresh_interval_hours: u64,
) {
let client = crate::main_helpers::providers_cmd::PROVIDER_REFRESH_CLIENT.clone();
tokio::spawn(async move {
let interval = std::time::Duration::from_secs(refresh_interval_hours * 3600);
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
loop {
let providers: Vec<_> = shared.provider_catalog.all_providers().cloned().collect();
for provider in providers {
if !provider.capabilities.chat_completions {
continue;
}
if provider.default_base_url.is_empty() {
continue;
}
let api_key = provider
.env_vars
.iter()
.find_map(|v| std::env::var(v.as_str()).ok());
if api_key.is_none() {
continue;
}
let url = format!(
"{}/v1/models",
provider.default_base_url.trim_end_matches('/')
);
let provider_id = provider.id.clone();
let mut req = client.get(&url);
if let Some(ref key) = api_key {
req = req.header("Authorization", format!("Bearer {key}"));
}
match req.send().await {
Err(e) => tracing::warn!(
provider = %provider_id,
error = %e,
"provider auto-refresh failed"
),
Ok(resp) if !resp.status().is_success() => tracing::warn!(
provider = %provider_id,
status = %resp.status(),
"provider auto-refresh upstream error"
),
Ok(resp) => match resp.json::<serde_json::Value>().await {
Err(e) => tracing::warn!(
provider = %provider_id,
error = %e,
"provider auto-refresh: invalid JSON response"
),
Ok(json) => {
let model_ids: Vec<String> = json
.get("data")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id")?.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let count = model_ids.len();
let db_ref = shared.db.clone();
let pid = provider_id.clone();
let _ = tokio::task::spawn_blocking(move || {
let mut conn_guard =
db_ref.lock().unwrap_or_else(|e| e.into_inner());
if let Err(e) = admin::db::upsert_provider_models_cache(
&mut conn_guard,
&pid,
&model_ids,
) {
tracing::warn!(
provider = %pid,
error = %e,
"failed to save auto-refresh results"
);
}
})
.await;
tracing::info!(
provider = %provider_id,
count = count,
"auto-refreshed provider model cache"
);
}
},
}
}
tokio::time::sleep(interval).await;
}
});
}
pub(crate) fn spawn_periodic_tasks(shared: admin::state::SharedState, retention_days: u32) {
// 1. Log retention task
let retention_db = shared.db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
interval.tick().await;
admin::state::with_db(
&retention_db,
move |conn_ref| match admin::db::purge_old_logs(conn_ref, retention_days) {
Ok(n) if n > 0 => {
tracing::info!(purged = n, "purged old request log entries")
}
Err(e) => tracing::error!(error = %e, "failed to purge old logs"),
_ => {}
},
)
.await;
}
});
// 2. Periodic metrics snapshot broadcast task
let snapshot_shared = shared.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
if snapshot_shared.events_tx.receiver_count() == 0 {
continue;
}
let mut aggregate = anyllm_proxy::metrics::MetricsSnapshot::default();
for m in snapshot_shared.backend_metrics.values() {
let snap = m.snapshot();
aggregate.requests_total += snap.requests_total;
aggregate.requests_error += snap.requests_error;
aggregate.requests_success += snap.requests_success;
aggregate.streams_started += snap.streams_started;
aggregate.streams_completed += snap.streams_completed;
aggregate.streams_failed += snap.streams_failed;
aggregate.streams_client_disconnected += snap.streams_client_disconnected;
}
let error_rate = aggregate.error_rate();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let since = now_secs.saturating_sub(60);
let rpm = admin::state::with_db(&snapshot_shared.db, move |conn_ref| {
admin::db::count_requests_since(conn_ref, since).unwrap_or(0)
})
.await
.unwrap_or(0) as f64;
let snapshot = admin::state::MetricsSnapshotData {
total_requests: aggregate.requests_total,
successful_requests: aggregate.requests_success,
failed_requests: aggregate.requests_error,
requests_per_minute: rpm,
p50_latency_ms: None,
p95_latency_ms: None,
error_rate,
streams_started: aggregate.streams_started,
streams_completed: aggregate.streams_completed,
streams_failed: aggregate.streams_failed,
streams_client_disconnected: aggregate.streams_client_disconnected,
};
let _ = snapshot_shared
.events_tx
.send(admin::state::AdminEvent::MetricsSnapshot(snapshot));
}
});
}
@@ -0,0 +1,168 @@
use crate::backend::anthropic_client::AnthropicClient;
use crate::openai_tool_policy::{
backend_kind_for_policy, validate_anthropic_tool_request, OpenAiToolPolicyContext,
};
use crate::server::routes::{
inject_degradation_header, log_request, record_virtual_key_usage, set_backend_error_kind,
try_cache_response, RequestCtx,
};
use crate::server::state::AppState;
use anyllm_translate::{
anthropic, translate_anthropic_to_openai_response_with_context, AnthropicTranslationContext,
TranslationWarnings,
};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
#[allow(clippy::too_many_arguments)]
pub(crate) async fn execute_anthropic_backend(
client: &AnthropicClient,
effective: &AppState,
state: &AppState,
anthropic_req: &anthropic::MessageCreateRequest,
raw_tools: &[serde_json::Value],
safe_headers: &[(String, String)],
tool_context: &AnthropicTranslationContext,
original_model: &str,
mapped_model: &str,
warnings: &mut TranslationWarnings,
deployment: &Option<std::sync::Arc<crate::config::model_router::Deployment>>,
backend_start: std::time::Instant,
vk_ctx: &Option<crate::server::middleware::VirtualKeyContext>,
ctx: &RequestCtx,
cache_control: &crate::cache::CacheControl,
store_cache_key: &Option<String>,
) -> Response {
let mut upstream_req = anthropic_req.clone();
upstream_req.model = mapped_model.to_string();
upstream_req.stream = Some(false);
if let Err(err) = validate_anthropic_tool_request(
&upstream_req,
OpenAiToolPolicyContext {
backend_kind: backend_kind_for_policy(&effective.backend),
provider_id: effective.provider_id.as_deref(),
model: mapped_model,
provider_catalog: &effective.provider_catalog,
},
) {
return super::helpers::openai_error_response(
err.message(),
"invalid_request_error",
StatusCode::BAD_REQUEST,
);
}
let body =
match super::extensions::serialize_anthropic_upstream_request(&upstream_req, raw_tools) {
Ok(body) => body,
Err(e) => {
return super::helpers::openai_error_response(
&format!("failed to serialize Anthropic request: {e}"),
"server_error",
StatusCode::INTERNAL_SERVER_ERROR,
);
}
};
let refs = super::helpers::header_refs(safe_headers);
match client.forward(body, &refs, None).await {
Ok((resp_body, rate_limits)) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
}
let anthropic_resp =
match serde_json::from_slice::<anthropic::MessageResponse>(&resp_body) {
Ok(resp) => resp,
Err(e) => {
state.metrics.record_error();
log_request(
&state.shared,
ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model.to_string()),
StatusCode::BAD_GATEWAY.as_u16(),
None,
false,
Some(format!("failed to parse Anthropic upstream response: {e}")),
vk_ctx,
None,
),
);
return super::helpers::openai_error_response(
"Upstream Anthropic response could not be parsed.",
"server_error",
StatusCode::BAD_GATEWAY,
);
}
};
state.metrics.record_success();
let oai_response = translate_anthropic_to_openai_response_with_context(
&anthropic_resp,
original_model,
tool_context,
);
let cost = record_virtual_key_usage(
&state.shared,
vk_ctx,
mapped_model,
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
);
log_request(
&state.shared,
ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model.to_string()),
200,
Some((
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
)),
false,
None,
vk_ctx,
Some(cost),
),
);
try_cache_response(
store_cache_key,
&state.cache,
cache_control.ttl_secs,
&oai_response,
original_model.to_string(),
)
.await;
let cache_hv = crate::server::routes::cache_header_value(!cache_control.lookup);
let mut response = (StatusCode::OK, axum::Json(oai_response)).into_response();
rate_limits.inject_anthropic_response_headers(response.headers_mut());
if state.expose_degradation_warnings {
inject_degradation_header(response.headers_mut(), warnings);
}
response.headers_mut().insert("x-anyllm-cache", cache_hv);
response
}
Err(e) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
}
state.metrics.record_error();
let backend_error = crate::backend::BackendError::from(e);
let mut entry = ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model.to_string()),
backend_error.status_code(),
None,
false,
Some(backend_error.to_string()),
vk_ctx,
None,
);
set_backend_error_kind(&mut entry, &backend_error);
log_request(&state.shared, entry);
super::helpers::backend_error_to_openai_response(backend_error)
}
}
}
@@ -0,0 +1,204 @@
use crate::backend::openai_client::OpenAIClient;
use crate::openai_tool_policy::{
backend_kind_for_policy, prepare_openai_tool_request, OpenAiToolPolicyContext,
};
use crate::server::routes::{
inject_degradation_header, inject_gemini_thinking, inject_glm_thinking, log_request,
record_virtual_key_usage, set_backend_error_kind, try_cache_response, RequestCtx,
};
use crate::server::state::AppState;
use anyllm_translate::{
anthropic, mapping, openai, translate_anthropic_to_openai_response, TranslationWarnings,
};
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
#[allow(clippy::too_many_arguments)]
pub(crate) async fn execute_openai_backend(
client: &OpenAIClient,
effective: &AppState,
state: &AppState,
anthropic_req: &anthropic::MessageCreateRequest,
_body: &openai::ChatCompletionRequest,
original_model: &str,
mapped_model: &str,
warnings: &mut TranslationWarnings,
deployment: &Option<std::sync::Arc<crate::config::model_router::Deployment>>,
backend_start: std::time::Instant,
vk_ctx: &Option<crate::server::middleware::VirtualKeyContext>,
ctx: &RequestCtx,
cache_control: &crate::cache::CacheControl,
store_cache_key: &Option<String>,
) -> Response {
let mut openai_req = mapping::message_map::anthropic_to_openai_request(anthropic_req);
inject_gemini_thinking(anthropic_req, &effective.backend, &mut openai_req);
inject_glm_thinking(anthropic_req, &effective.backend, &mut openai_req);
if effective.omit_stream_options {
openai_req.stream_options = None;
}
openai_req.model = mapped_model.to_string();
// Opt-in RTK tool-output compression (OpenAI-in translate path).
effective.apply_rtk_to_openai(&mut openai_req, mapped_model);
if let Err(err) = prepare_openai_tool_request(
&mut openai_req,
OpenAiToolPolicyContext {
backend_kind: backend_kind_for_policy(&effective.backend),
provider_id: effective.provider_id.as_deref(),
model: mapped_model,
provider_catalog: &effective.provider_catalog,
},
warnings,
) {
return super::helpers::openai_error_response(
err.message(),
"invalid_request_error",
StatusCode::BAD_REQUEST,
);
}
let mapped_model = openai_req.model.clone();
match client.chat_completion(&openai_req).await {
Ok((openai_resp, _status, rate_limits)) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
}
state.metrics.record_success();
// Translate Anthropic response back to OpenAI format
let anthropic_resp =
mapping::message_map::openai_to_anthropic_response(&openai_resp, original_model);
// Tool execution: bounded loop with termination guards.
let anthropic_resp = if let Some(ref engine) = effective.tool_engine {
let client_for_tools = client.clone();
let model_for_tools = mapped_model.clone();
let orig_model_for_tools = original_model.to_string();
let redact_follow_up = effective.redact_secrets();
let backend_kind_for_tools = backend_kind_for_policy(&effective.backend);
let provider_id_for_tools = effective.provider_id.clone();
let provider_catalog_for_tools = effective.provider_catalog.clone();
let server_advertised_tool_names = std::collections::HashSet::new();
let guardrails_for_tools = effective.effective_tool_guardrails(engine);
let (resp, _trace) = crate::tools::execution::maybe_execute_tools(
engine,
anthropic_req,
&server_advertised_tool_names,
anthropic_resp,
&guardrails_for_tools,
|follow_up_req| {
let c = client_for_tools.clone();
let m = model_for_tools.clone();
let policy_model = m.clone();
let om = orig_model_for_tools.clone();
let backend_kind = backend_kind_for_tools.clone();
let provider_id = provider_id_for_tools.clone();
let provider_catalog = provider_catalog_for_tools.clone();
async move {
let follow_up_req =
match crate::server::secret_redaction::redact_json_value(
redact_follow_up,
follow_up_req,
)
.await
{
Ok(req) => req,
Err(err) => return Err(err.safe_message().to_string()),
};
let mut oai_req =
mapping::message_map::anthropic_to_openai_request(&follow_up_req);
oai_req.model = m;
let mut follow_up_warnings = TranslationWarnings::default();
prepare_openai_tool_request(
&mut oai_req,
OpenAiToolPolicyContext {
backend_kind,
provider_id: provider_id.as_deref(),
model: &policy_model,
provider_catalog: &provider_catalog,
},
&mut follow_up_warnings,
)
.map_err(|err| err.to_string())?;
match c.chat_completion(&oai_req).await {
Ok((resp, _, _)) => Ok(
mapping::message_map::openai_to_anthropic_response(&resp, &om),
),
Err(e) => Err(format!("{e}")),
}
}
},
)
.await;
resp
} else {
anthropic_resp
};
let oai_response =
translate_anthropic_to_openai_response(&anthropic_resp, original_model);
let cost = record_virtual_key_usage(
&state.shared,
vk_ctx,
&mapped_model,
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
);
log_request(
&state.shared,
ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model),
200,
Some((
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
)),
false,
None,
vk_ctx,
Some(cost),
),
);
try_cache_response(
store_cache_key,
&state.cache,
cache_control.ttl_secs,
&oai_response,
original_model.to_string(),
)
.await;
let cache_hv = crate::server::routes::cache_header_value(!cache_control.lookup);
let mut response = (StatusCode::OK, Json(oai_response)).into_response();
rate_limits.inject_anthropic_response_headers(response.headers_mut());
if state.expose_degradation_warnings {
inject_degradation_header(response.headers_mut(), warnings);
}
response.headers_mut().insert("x-anyllm-cache", cache_hv);
response
}
Err(e) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
}
state.metrics.record_error();
let backend_error = crate::backend::BackendError::from(e);
let mut entry = ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model),
backend_error.status_code(),
None,
false,
Some(backend_error.to_string()),
vk_ctx,
None,
);
set_backend_error_kind(&mut entry, &backend_error);
log_request(&state.shared, entry);
super::helpers::backend_error_to_openai_response(backend_error)
}
}
}
@@ -0,0 +1,110 @@
use crate::backend::openai_client::OpenAIClient;
use crate::server::routes::{
inject_degradation_header, log_request, record_virtual_key_usage, set_backend_error_kind,
try_cache_response, RequestCtx,
};
use crate::server::state::AppState;
use anyllm_translate::{
anthropic, mapping, translate_anthropic_to_openai_response, TranslationWarnings,
};
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
#[allow(clippy::too_many_arguments)]
pub(crate) async fn execute_responses_backend(
client: &OpenAIClient,
state: &AppState,
anthropic_req: &anthropic::MessageCreateRequest,
original_model: &str,
mapped_model: &str,
warnings: &mut TranslationWarnings,
deployment: &Option<std::sync::Arc<crate::config::model_router::Deployment>>,
backend_start: std::time::Instant,
vk_ctx: &Option<crate::server::middleware::VirtualKeyContext>,
ctx: &RequestCtx,
cache_control: &crate::cache::CacheControl,
store_cache_key: &Option<String>,
) -> Response {
let mut responses_req =
mapping::responses_message_map::anthropic_to_responses_request(anthropic_req);
responses_req.model = mapped_model.to_string();
let mapped_model = responses_req.model.clone();
match client.responses(&responses_req).await {
Ok((resp, _status, rate_limits)) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
}
state.metrics.record_success();
let anthropic_resp = mapping::responses_message_map::responses_to_anthropic_response(
&resp,
original_model,
);
let oai_response =
translate_anthropic_to_openai_response(&anthropic_resp, original_model);
let cost = record_virtual_key_usage(
&state.shared,
vk_ctx,
&mapped_model,
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
);
log_request(
&state.shared,
ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model),
200,
Some((
anthropic_resp.usage.input_tokens as u64,
anthropic_resp.usage.output_tokens as u64,
)),
false,
None,
vk_ctx,
Some(cost),
),
);
try_cache_response(
store_cache_key,
&state.cache,
cache_control.ttl_secs,
&oai_response,
original_model.to_string(),
)
.await;
let cache_hv = crate::server::routes::cache_header_value(!cache_control.lookup);
let mut response = (StatusCode::OK, Json(oai_response)).into_response();
rate_limits.inject_anthropic_response_headers(response.headers_mut());
if state.expose_degradation_warnings {
inject_degradation_header(response.headers_mut(), warnings);
}
response.headers_mut().insert("x-anyllm-cache", cache_hv);
response
}
Err(e) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
}
state.metrics.record_error();
let backend_error = crate::backend::BackendError::from(e);
let mut entry = ctx.log_entry_with_attribution(
&state.backend_name,
Some(mapped_model),
backend_error.status_code(),
None,
false,
Some(backend_error.to_string()),
vk_ctx,
None,
);
set_backend_error_kind(&mut entry, &backend_error);
log_request(&state.shared, entry);
super::helpers::backend_error_to_openai_response(backend_error)
}
}
}
@@ -0,0 +1,42 @@
use crate::backend::anthropic_client::AnthropicClientError;
use anyllm_translate::{anthropic, mapping};
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
pub(crate) fn passthrough_error_to_response(error: AnthropicClientError) -> Response {
match error {
AnthropicClientError::ApiError { status, body } => {
let http_status =
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(http_status, [("content-type", "application/json")], body).into_response()
}
AnthropicClientError::Transport(msg) => {
tracing::error!("Anthropic passthrough transport error: {msg}");
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::ApiError,
"An internal error occurred while communicating with the upstream service."
.to_string(),
None,
);
(StatusCode::BAD_GATEWAY, Json(err)).into_response()
}
}
}
pub(crate) fn passthrough_error_status(error: &AnthropicClientError) -> u16 {
match error {
AnthropicClientError::ApiError { status, .. } => *status,
AnthropicClientError::Transport(_) => StatusCode::BAD_GATEWAY.as_u16(),
}
}
pub(crate) fn virtual_key_accounting_parse_error() -> Response {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::ApiError,
"Upstream response could not be accounted for this virtual API key.".to_string(),
None,
);
(StatusCode::BAD_GATEWAY, Json(err)).into_response()
}
@@ -0,0 +1,124 @@
use crate::backend::BackendClient;
use crate::server::middleware::ClientAuthPath;
use crate::server::state::AppState;
use anyllm_translate::{anthropic, mapping};
use axum::{
body::Bytes,
extract::{OriginalUri, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use super::super::auth::resolve_client_auth_override;
use super::errors::passthrough_error_to_response;
/// Generic catch-all passthrough for any /v1/* path in Anthropic mode.
/// Forwards batch, file CRUD, count_tokens, and other Anthropic-native endpoints
/// directly to the upstream Anthropic API. Registered after /v1/messages so that
/// route retains its dedicated streaming/model-peek logic.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn anthropic_generic_passthrough(
State(state): State<AppState>,
vk_ctx: Option<axum::Extension<crate::server::middleware::VirtualKeyContext>>,
auth_path: Option<axum::Extension<ClientAuthPath>>,
claims: Option<axum::Extension<crate::server::oidc::JwtClaims>>,
OriginalUri(uri): OriginalUri,
method: axum::http::Method,
headers: axum::http::HeaderMap,
body: Bytes,
) -> Response {
state.metrics.record_request();
// Virtual keys must use policy-aware handlers only.
if vk_ctx.is_some() {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::PermissionError,
"This endpoint is not available for virtual API keys.".to_string(),
None,
);
return (StatusCode::FORBIDDEN, Json(err)).into_response();
}
let vk_ctx = vk_ctx.map(|axum::Extension(c)| c);
let auth_path = auth_path.map(|axum::Extension(p)| p);
let claims = claims.map(|axum::Extension(c)| c);
let auth_override_ref = resolve_client_auth_override(
state.forward_client_auth_enabled(),
auth_path,
&vk_ctx,
&claims,
&headers,
);
let client = match &state.backend {
BackendClient::Anthropic(c) => c,
_ => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::ApiError,
"Backend is not configured as anthropic passthrough".to_string(),
None,
);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(err)).into_response();
}
};
let mut full_path = uri.path().to_string();
if let Some(q) = uri.query() {
full_path.push('?');
full_path.push_str(q);
}
let session_id = headers
.get("x-claude-code-session-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let beta = headers
.get("anthropic-beta")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let mut extra: Vec<(&str, &str)> = Vec::new();
if let Some(ref v) = session_id {
extra.push(("x-claude-code-session-id", v));
}
if let Some(ref v) = beta {
extra.push(("anthropic-beta", v));
}
let body =
match crate::server::secret_redaction::redact_body(state.redact_secrets(), &headers, body)
.await
{
Ok(body) => body,
Err(err) => return crate::server::secret_redaction::error_response(err),
};
match client
.forward_generic(method, &full_path, body, &extra, auth_override_ref)
.await
{
Ok(response) => {
let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK);
if status.is_success() {
state.metrics.record_success();
} else {
state.metrics.record_error();
}
let upstream_ct = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let stream = response.bytes_stream();
let axum_body = axum::body::Body::from_stream(stream);
let mut resp = (status, axum_body).into_response();
if let Ok(hv) = axum::http::HeaderValue::from_str(&upstream_ct) {
resp.headers_mut().insert("content-type", hv);
}
resp
}
Err(e) => {
state.metrics.record_error();
passthrough_error_to_response(e)
}
}
}
@@ -9,7 +9,7 @@ use crate::server::streaming::{observe_anthropic_sse_frames, AnthropicStreamUsag
use anyllm_translate::{anthropic, mapping};
use axum::{
body::Bytes,
extract::{OriginalUri, State},
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Response},
};
@@ -18,7 +18,10 @@ use futures::StreamExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use super::auth::resolve_client_auth_override;
use super::super::auth::resolve_client_auth_override;
use super::errors::{
passthrough_error_status, passthrough_error_to_response, virtual_key_accounting_parse_error,
};
/// Forward an Anthropic-format request byte-for-byte to the upstream Anthropic API.
/// No translation is performed. Only active when `BACKEND=anthropic`.
@@ -37,12 +40,6 @@ pub(crate) async fn anthropic_passthrough(
let claims = claims.map(|axum::Extension(c)| c);
state.metrics.record_request();
// Verbatim client-credential override for ANTHROPIC_FORWARD_CLIENT_AUTH:
// computed once per request, reused across the streaming/non-streaming
// branches below. Borrowed straight from `headers` (which outlives both
// branches and is never mutated), not owned -- `forward`/`forward_stream`
// consume it synchronously before the branch that does
// `tokio::spawn`, so there's no need to outlive the spawned task.
let auth_override_ref = resolve_client_auth_override(
state.forward_client_auth_enabled(),
auth_path,
@@ -51,16 +48,6 @@ pub(crate) async fn anthropic_passthrough(
&headers,
);
// Scopes every thinking-repair store lookup/commit to this backend and
// virtual key: `state.thinking_repair` is one store shared across every
// Anthropic-mode backend (see server/routes.rs), so without this a
// colliding message id / thinking signature / tool_use id from a
// different backend or tenant could resolve to this request's repair.
// NUL, not `:`, joins the two parts: `state.backend_name` is an
// operator-configured string whose validated charset (`is_safe_model_name`)
// allows `:` but not NUL, so a backend literally named e.g. "anthropic:5"
// can no longer produce the same namespace as backend "anthropic" + key
// id 5 -- `:` let those collide onto one shared cache-record namespace.
let thinking_repair_namespace = match &vk_ctx {
Some(ctx) => format!("{}\u{0}{}", state.backend_name, ctx.key_id),
None => state.backend_name.clone(),
@@ -78,9 +65,6 @@ pub(crate) async fn anthropic_passthrough(
}
};
// Collect Anthropic-specific client headers to forward upstream.
// anthropic-beta enables beta features; must reach upstream to take effect.
// x-claude-code-session-id allows upstream and intermediary proxies to correlate sessions.
let extra_headers: Vec<(&str, &str)> = ["x-claude-code-session-id", "anthropic-beta"]
.iter()
.filter_map(|&name| {
@@ -91,9 +75,6 @@ pub(crate) async fn anthropic_passthrough(
})
.collect();
// Peek at just the `stream` and `model` fields instead of parsing the full body.
// Full deserialization would be wasteful for image-heavy requests
// (up to 32MB) when we only need one boolean to choose the handler.
#[derive(serde::Deserialize)]
struct BodyPeek {
#[serde(default)]
@@ -115,7 +96,6 @@ pub(crate) async fn anthropic_passthrough(
model_requested: peek.model.clone().unwrap_or_else(|| "unknown".to_string()),
};
// Enforce model allowlist for virtual keys.
if let Some(ref ctx) = vk_ctx {
match &peek.model {
Some(m) => {
@@ -129,8 +109,6 @@ pub(crate) async fn anthropic_passthrough(
}
}
None => {
// If a model allowlist is configured, we cannot verify the request
// is permitted without knowing the model. Reject rather than bypass.
if ctx.allowed_models.is_some() {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::InvalidRequestError,
@@ -144,18 +122,8 @@ pub(crate) async fn anthropic_passthrough(
}
}
// pxpipe compression is decided here but APPLIED after secret redaction
// below: imaging bakes the system/tool_result text into PNG pixels, which the
// text-based redactor cannot see, so redaction must run on the raw text first.
let mut pxpipe_apply: Option<(std::sync::Arc<crate::pxpipe::PxpipeEngine>, String)> = None;
// RTK tool-output compression, likewise deferred to after redaction and run
// BEFORE pxpipe so imaging sees the already-filtered text.
let mut rtk_apply: Option<(std::sync::Arc<crate::rtk::RtkEngine>, String)> = None;
// FFEC prompt compression (history + frontier cache_control breakpoint). Runs
// on the raw Value bytes (never round-tripping MessageCreateRequest, which has
// no cache_control field), so the breakpoint survives to the Anthropic API. The
// `.filter` gates the (up to 32MB) parse: only capture when the resolved mode is
// not Off. Does not need the parsed model, so it sits outside the parse block.
let optimizer_apply = state
.effective_optimizer()
.filter(|e| e.mode() != anyllm_optimize_core::Mode::Off);
@@ -177,10 +145,6 @@ pub(crate) async fn anthropic_passthrough(
return (StatusCode::BAD_REQUEST, Json(err)).into_response();
}
// Repair the last assistant message's thinking blocks against
// recorded ground truth (opt-in, see crate::thinking_repair). Only
// rewrites `body` when something actually changed; on a byte-exact
// replay (the common case) this is a no-op past the store lookups.
if let Some(store) = state.active_thinking_repair() {
if let Some(what) = crate::thinking_repair::repair_request(
&store,
@@ -189,21 +153,12 @@ pub(crate) async fn anthropic_passthrough(
)
.await
{
// Patch only the repaired message's `content` into the
// ORIGINAL raw JSON instead of re-serializing the whole
// typed request: ContentBlock/Tool have no cache_control or
// flatten catch-all, so a full-struct round-trip would
// silently drop cache_control breakpoints and any block/tool
// type this crate doesn't model yet, on every OTHER message
// too.
match crate::thinking_repair::patch_repaired_body(&body, &parsed_req) {
Ok(bytes) => {
tracing::info!(repair = %what, "anthropic thinking-block repair applied");
body = bytes;
}
Err(e) => {
// Fail open: forward the original (unrepaired) bytes
// rather than drop the request.
tracing::warn!(
error = %e,
"failed to patch repaired anthropic request; forwarding original body"
@@ -213,11 +168,6 @@ pub(crate) async fn anthropic_passthrough(
}
}
// Opt-in text-to-image context compression (pxpipe). Decided here (needs
// the parsed model for scope/vision gating) but deferred to after
// redaction — see the `pxpipe_apply` capture note above. The transform
// works on the raw `body` bytes (Value-level) so it can RELOCATE the
// caller's cache_control anchor onto the image; it fails open on any error.
if let Some(engine) = state.pxpipe_engine_for(&parsed_req.model) {
pxpipe_apply = Some((engine, parsed_req.model.clone()));
}
@@ -237,19 +187,10 @@ pub(crate) async fn anthropic_passthrough(
Err(err) => return crate::server::secret_redaction::error_response(err),
};
// FFEC prompt compression runs FIRST: it compresses conversation history text
// and places the frontier cache_control breakpoint. It MUST precede pxpipe,
// which images the static message slab into a PNG — after imaging there is no
// history text left to compress and no place for a breakpoint. Combining the
// optimizer's breakpoint with pxpipe's cache_control-anchor relocation is
// untested (both are opt-in); we don't try to reconcile them here. Fails open.
if let Some(engine) = optimizer_apply {
body = engine.optimize_anthropic_bytes(body, "messages", &state.metrics);
}
// Now that secrets are redacted, compress tool output (RTK) then image the
// static slab / large live regions (pxpipe). RTK first so pxpipe images the
// already-filtered text; both fail open.
if let Some((engine, model)) = rtk_apply {
body = engine.compress_anthropic(body, &model, &state.metrics);
}
@@ -268,8 +209,6 @@ pub(crate) async fn anthropic_passthrough(
let log_shared = state.shared.clone();
let log_backend_name = state.backend_name.clone();
let cost_model = peek.model.clone().unwrap_or_else(|| "unknown".to_string());
// Captured once, before the stream starts: a toggle mid-stream
// must not half-record ground truth.
let thinking_repair = state.active_thinking_repair();
let thinking_repair_namespace = thinking_repair_namespace.clone();
@@ -390,10 +329,6 @@ pub(crate) async fn anthropic_passthrough(
.await
{
Ok((resp_body, rate_limits)) => {
// Parsed once and shared between thinking-repair recording
// and virtual-key accounting below (previously each parsed
// the same bytes independently, and recording additionally
// cloned the whole content Vec instead of moving it out).
let mut parsed_resp =
serde_json::from_slice::<anthropic::MessageResponse>(&resp_body);
@@ -491,167 +426,3 @@ pub(crate) async fn anthropic_passthrough(
}
}
}
/// Generic catch-all passthrough for any /v1/* path in Anthropic mode.
/// Forwards batch, file CRUD, count_tokens, and other Anthropic-native endpoints
/// directly to the upstream Anthropic API. Registered after /v1/messages so that
/// route retains its dedicated streaming/model-peek logic.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn anthropic_generic_passthrough(
State(state): State<AppState>,
vk_ctx: Option<axum::Extension<crate::server::middleware::VirtualKeyContext>>,
auth_path: Option<axum::Extension<ClientAuthPath>>,
claims: Option<axum::Extension<crate::server::oidc::JwtClaims>>,
OriginalUri(uri): OriginalUri,
method: axum::http::Method,
headers: axum::http::HeaderMap,
body: Bytes,
) -> Response {
state.metrics.record_request();
// Virtual keys must use policy-aware handlers only. The generic passthrough
// can reach Anthropic-native endpoints that lack per-key authorization,
// resource ownership checks, and usage accounting.
if vk_ctx.is_some() {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::PermissionError,
"This endpoint is not available for virtual API keys.".to_string(),
None,
);
return (StatusCode::FORBIDDEN, Json(err)).into_response();
}
let vk_ctx = vk_ctx.map(|axum::Extension(c)| c);
let auth_path = auth_path.map(|axum::Extension(p)| p);
let claims = claims.map(|axum::Extension(c)| c);
// vk_ctx is already rejected above (so resolve_client_auth_override's
// vk_ctx.is_none() check is always true here), but an OIDC-authenticated
// non-virtual-key request can still reach here, so this must also be
// gated on claims/auth_path (never forward a JWT upstream as if it were
// an Anthropic credential).
let auth_override_ref = resolve_client_auth_override(
state.forward_client_auth_enabled(),
auth_path,
&vk_ctx,
&claims,
&headers,
);
let client = match &state.backend {
BackendClient::Anthropic(c) => c,
_ => {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::ApiError,
"Backend is not configured as anthropic passthrough".to_string(),
None,
);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(err)).into_response();
}
};
// Build full path with query string preserved.
let mut full_path = uri.path().to_string();
if let Some(q) = uri.query() {
full_path.push('?');
full_path.push_str(q);
}
// Collect owned Strings before building the &str slice (lifetime requirement).
let session_id = headers
.get("x-claude-code-session-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let beta = headers
.get("anthropic-beta")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let mut extra: Vec<(&str, &str)> = Vec::new();
if let Some(ref v) = session_id {
extra.push(("x-claude-code-session-id", v));
}
if let Some(ref v) = beta {
extra.push(("anthropic-beta", v));
}
let body =
match crate::server::secret_redaction::redact_body(state.redact_secrets(), &headers, body)
.await
{
Ok(body) => body,
Err(err) => return crate::server::secret_redaction::error_response(err),
};
match client
.forward_generic(method, &full_path, body, &extra, auth_override_ref)
.await
{
Ok(response) => {
let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK);
if status.is_success() {
state.metrics.record_success();
} else {
state.metrics.record_error();
}
// Preserve upstream content-type (batches return application/x-jsonl, etc.)
let upstream_ct = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let stream = response.bytes_stream();
let axum_body = axum::body::Body::from_stream(stream);
let mut resp = (status, axum_body).into_response();
if let Ok(hv) = axum::http::HeaderValue::from_str(&upstream_ct) {
resp.headers_mut().insert("content-type", hv);
}
resp
}
Err(e) => {
state.metrics.record_error();
passthrough_error_to_response(e)
}
}
}
/// Convert an AnthropicClientError into a Response.
/// For API errors, return the upstream error body directly (it's already Anthropic format).
fn passthrough_error_to_response(
error: crate::backend::anthropic_client::AnthropicClientError,
) -> Response {
use crate::backend::anthropic_client::AnthropicClientError;
match error {
AnthropicClientError::ApiError { status, body } => {
let http_status =
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(http_status, [("content-type", "application/json")], body).into_response()
}
AnthropicClientError::Transport(msg) => {
tracing::error!("Anthropic passthrough transport error: {msg}");
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::ApiError,
"An internal error occurred while communicating with the upstream service."
.to_string(),
None,
);
(StatusCode::BAD_GATEWAY, Json(err)).into_response()
}
}
}
fn passthrough_error_status(error: &crate::backend::anthropic_client::AnthropicClientError) -> u16 {
match error {
crate::backend::anthropic_client::AnthropicClientError::ApiError { status, .. } => *status,
crate::backend::anthropic_client::AnthropicClientError::Transport(_) => {
StatusCode::BAD_GATEWAY.as_u16()
}
}
}
fn virtual_key_accounting_parse_error() -> Response {
let err = mapping::errors_map::create_anthropic_error(
anthropic::ErrorType::ApiError,
"Upstream response could not be accounted for this virtual API key.".to_string(),
None,
);
(StatusCode::BAD_GATEWAY, Json(err)).into_response()
}
@@ -0,0 +1,6 @@
pub(crate) mod errors;
pub(crate) mod generic;
pub(crate) mod messages;
pub(crate) use generic::anthropic_generic_passthrough;
pub(crate) use messages::anthropic_passthrough;
@@ -1,261 +1,24 @@
// SSE streaming infrastructure and the messages_stream handler.
use crate::backend::{find_double_newline, BackendClient, RateLimitHeaders, SseFrameBuffer};
use crate::metrics::Metrics;
use crate::backend::{BackendClient, RateLimitHeaders};
use crate::openai_tool_policy::{
backend_kind_for_policy, parse_openai_chat_completion_chunk, prepare_openai_tool_request,
tool_policy_error_to_backend_error, OpenAiToolPolicyContext,
};
use crate::server::routes::{log_request, set_backend_error_kind, RequestCtx};
use crate::server::state::AppState;
use anyllm_translate::{anthropic, mapping, TranslationWarnings};
use axum::response::sse::{Event, KeepAlive, Sse};
use bytes::BytesMut;
use futures::stream::Stream;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use super::routes::{log_request, set_backend_error_kind, RequestCtx};
use super::state::AppState;
use super::helpers::{read_sse_frames, send_events, StreamDeploymentAccounting, StreamOutcome};
pub(crate) struct StreamDeploymentAccounting {
deployment: Option<Arc<crate::config::model_router::Deployment>>,
start: Option<Instant>,
}
impl StreamDeploymentAccounting {
pub(crate) fn start(deployment: Option<Arc<crate::config::model_router::Deployment>>) -> Self {
if let Some(deployment) = &deployment {
deployment.record_start();
Self {
deployment: Some(deployment.clone()),
start: Some(Instant::now()),
}
} else {
Self {
deployment: None,
start: None,
}
}
}
pub(crate) fn finish(&mut self) {
if let (Some(deployment), Some(start)) = (&self.deployment, self.start.take()) {
deployment.record_finish(start.elapsed().as_millis() as u64);
}
}
}
impl Drop for StreamDeploymentAccounting {
fn drop(&mut self) {
self.finish();
}
}
/// Send translated stream events over the SSE channel. Returns false if client disconnected.
pub(super) async fn send_events(
tx: &mpsc::Sender<Result<Event, std::convert::Infallible>>,
events: &[anthropic::StreamEvent],
) -> bool {
for ev in events {
match super::sse::stream_event_to_sse(ev) {
Ok(sse) => {
if tx.send(Ok(sse)).await.is_err() {
return false;
}
}
Err(e) => {
tracing::warn!("failed to serialize stream event: {e}");
}
}
}
true
}
/// Why the SSE stream ended.
#[derive(Debug, Clone, Copy)]
pub(crate) enum StreamOutcome {
/// Backend stream completed normally.
Completed,
/// Downstream client disconnected before the stream finished.
ClientDisconnected,
/// Backend stream failed (error already recorded in metrics).
UpstreamError,
}
impl StreamOutcome {
/// Record metrics and return (HTTP status, error message) for logging.
pub(crate) fn record(&self, metrics: &Metrics) -> (u16, Option<String>) {
match self {
Self::Completed => {
metrics.record_success();
metrics.record_stream_completed();
(200, None)
}
Self::ClientDisconnected => {
metrics.record_stream_client_disconnected();
(499, Some("client disconnected".into()))
}
Self::UpstreamError => {
metrics.record_stream_failed();
(502, Some("stream interrupted".into()))
}
}
}
}
#[cfg(test)]
mod tests;
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct AnthropicStreamUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
}
impl AnthropicStreamUsage {
pub(crate) fn observe_data(&mut self, data: &str) {
if data == "[DONE]" {
return;
}
let Ok(event) = serde_json::from_str::<anthropic::StreamEvent>(data) else {
return;
};
match event {
anthropic::StreamEvent::MessageStart { message } => {
self.input_tokens = Some(message.usage.input_tokens as u64);
}
anthropic::StreamEvent::MessageDelta {
usage: Some(usage), ..
} => {
self.output_tokens = Some(usage.output_tokens as u64);
}
_ => {}
}
}
pub(crate) fn tokens(&self) -> Option<(u64, u64)> {
match (self.input_tokens, self.output_tokens) {
(Some(input), Some(output)) => Some((input, output)),
(Some(input), None) => Some((input, 0)),
(None, Some(output)) => Some((0, output)),
(None, None) => None,
}
}
}
/// Parse buffered SSE frames, updating token usage and (when `recorder` is
/// `Some`) accumulating content blocks for the thinking-block repair store.
/// Completed messages (from `message_stop`) are pushed onto `ready` for the
/// caller to commit — accumulation here is synchronous, but committing to
/// the store is async, so it can't happen inline in this loop.
pub(crate) fn observe_anthropic_sse_frames(
buffer: &mut BytesMut,
search_from: &mut usize,
usage: &mut AnthropicStreamUsage,
mut recorder: Option<&mut crate::thinking_repair::ThinkingRecorder>,
ready: &mut Vec<(String, Vec<anthropic::ContentBlock>)>,
) {
while let Some((pos, delim_len)) = find_double_newline(buffer, *search_from) {
if let Ok(frame_str) = std::str::from_utf8(&buffer[..pos]) {
for line in frame_str.lines() {
let line = line.trim();
if let Some(json_str) = line.strip_prefix("data: ") {
usage.observe_data(json_str);
if let Some(rec) = recorder.as_deref_mut() {
if let Some(done) = rec.observe_json(json_str) {
ready.push(done);
}
}
}
}
}
let _ = buffer.split_to(pos + delim_len);
*search_from = 0;
}
*search_from = buffer.len().saturating_sub(3);
}
/// Read SSE bytes from a response, parse frames, and call `on_data` for each data line.
pub(super) async fn read_sse_frames<F>(
response: reqwest::Response,
tx: &mpsc::Sender<Result<Event, std::convert::Infallible>>,
metrics: &Metrics,
mut on_data: F,
) -> StreamOutcome
where
F: FnMut(&str) -> Option<Vec<anthropic::StreamEvent>>,
{
use futures::StreamExt;
let mut stream = response.bytes_stream();
// Buffer bytes (not String) because TCP chunks may split mid-UTF-8 character.
// String::from_utf8_lossy would permanently replace partial trailing bytes
// with U+FFFD, corrupting the JSON payload.
let mut buffer = SseFrameBuffer::new();
// Reuse a single events buffer across all frames to avoid per-frame allocation
let mut frame_events: Vec<anthropic::StreamEvent> = Vec::new();
while let Some(chunk_result) = stream.next().await {
let bytes = match chunk_result {
Ok(b) => b,
Err(e) => {
tracing::error!("stream read error: {e}");
metrics.record_error();
return StreamOutcome::UpstreamError;
}
};
let frames = match buffer.push(&bytes) {
Ok(frames) => frames,
Err(e) => {
tracing::error!(error = %e, "SSE buffer exceeded maximum size, aborting stream");
metrics.record_error();
return StreamOutcome::UpstreamError;
}
};
for frame in frames {
frame_events.clear();
// Convert the complete frame bytes to UTF-8. A frame ending at
// a double-newline boundary should always be valid UTF-8; if not,
// skip the malformed frame rather than injecting replacement chars.
match std::str::from_utf8(&frame) {
Ok(frame_str) => {
for line in frame_str.lines() {
let line = line.trim();
if let Some(json_str) = line.strip_prefix("data: ") {
if let Some(mut events) = on_data(json_str) {
frame_events.append(&mut events);
}
}
}
}
Err(e) => {
tracing::warn!("skipping non-UTF-8 SSE frame: {e}");
}
}
if !send_events(tx, &frame_events).await {
tracing::debug!("client disconnected during stream");
return StreamOutcome::ClientDisconnected;
}
}
}
StreamOutcome::Completed
}
/// Build an SSE response that streams Anthropic events translated from backend chunks.
/// Returns rate limit headers alongside the SSE stream so the caller can inject them.
/// Pre-stream backend errors (e.g., 401, 429, 500 before any data) are returned as
/// `Err(BackendError)` so the caller can respond with a proper HTTP status code.
/// Logging is deferred: each spawned task logs after the stream completes with actual
/// latency, status, and token counts.
pub(crate) async fn messages_stream(
state: AppState,
body: anthropic::MessageCreateRequest,
ctx: RequestCtx,
mapped_model: String,
concurrency_permit: Option<super::state::ConcurrencyPermit>,
concurrency_permit: Option<crate::server::state::ConcurrencyPermit>,
vk_ctx: Option<crate::server::middleware::VirtualKeyContext>,
deployment_accounting: StreamDeploymentAccounting,
) -> Result<
@@ -281,8 +44,8 @@ pub(crate) async fn messages_stream(
| BackendClient::GeminiOpenAI(client) => {
let client = client.clone();
let mut openai_req = mapping::message_map::anthropic_to_openai_request(&body);
super::routes::inject_gemini_thinking(&body, &state.backend, &mut openai_req);
super::routes::inject_glm_thinking(&body, &state.backend, &mut openai_req);
crate::server::routes::inject_gemini_thinking(&body, &state.backend, &mut openai_req);
crate::server::routes::inject_glm_thinking(&body, &state.backend, &mut openai_req);
if state.omit_stream_options {
openai_req.stream_options = None;
}
@@ -302,7 +65,6 @@ pub(crate) async fn messages_stream(
return Err(tool_policy_error_to_backend_error(err));
}
// Opt-in RTK tool-output compression on the streaming translate path.
state.apply_rtk_to_openai(&mut openai_req, &mapped_model);
let model = body.model.clone();
@@ -310,9 +72,6 @@ pub(crate) async fn messages_stream(
let mut deployment_accounting = deployment_accounting;
tokio::spawn(async move {
// Hold concurrency permit until the stream completes, not just
// until headers are sent, so the semaphore accurately bounds
// concurrent streaming connections.
let _permit = permit;
metrics.record_stream_started();
match client.chat_completion_stream(&openai_req).await {
@@ -363,7 +122,7 @@ pub(crate) async fn messages_stream(
let usage = translator.usage();
let tokens = usage.map(|u| (u.input_tokens as u64, u.output_tokens as u64));
let cost = tokens.map(|(input_t, output_t)| {
super::routes::record_virtual_key_usage(
crate::server::routes::record_virtual_key_usage(
&log_shared,
&vk_ctx,
&mapped_model,
@@ -402,8 +161,6 @@ pub(crate) async fn messages_stream(
);
set_backend_error_kind(&mut entry, &backend_error);
log_request(&log_shared, entry);
// Send the error through the oneshot so the caller can
// return a proper HTTP error response instead of 200 OK.
let _ = rl_tx.send(Err(backend_error));
deployment_accounting.finish();
}
@@ -472,7 +229,7 @@ pub(crate) async fn messages_stream(
let usage = translator.usage();
let tokens = usage.map(|u| (u.input_tokens as u64, u.output_tokens as u64));
let cost = tokens.map(|(input_t, output_t)| {
super::routes::record_virtual_key_usage(
crate::server::routes::record_virtual_key_usage(
&log_shared,
&vk_ctx,
&mapped_model,
@@ -535,8 +292,6 @@ pub(crate) async fn messages_stream(
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()),
)),
Ok(Err(backend_err)) => Err(backend_err),
// Sender dropped without sending (e.g., Anthropic passthrough branch or task panic).
// Default to empty rate limits and let the stream deliver whatever it has.
Err(_) => Ok((
RateLimitHeaders::default(),
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()),
@@ -0,0 +1,212 @@
use crate::backend::{find_double_newline, SseFrameBuffer};
use crate::metrics::Metrics;
use anyllm_translate::anthropic;
use axum::response::sse::Event;
use bytes::BytesMut;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
pub(crate) struct StreamDeploymentAccounting {
deployment: Option<Arc<crate::config::model_router::Deployment>>,
start: Option<Instant>,
}
impl StreamDeploymentAccounting {
pub(crate) fn start(deployment: Option<Arc<crate::config::model_router::Deployment>>) -> Self {
if let Some(deployment) = &deployment {
deployment.record_start();
Self {
deployment: Some(deployment.clone()),
start: Some(Instant::now()),
}
} else {
Self {
deployment: None,
start: None,
}
}
}
pub(crate) fn finish(&mut self) {
if let (Some(deployment), Some(start)) = (&self.deployment, self.start.take()) {
deployment.record_finish(start.elapsed().as_millis() as u64);
}
}
}
impl Drop for StreamDeploymentAccounting {
fn drop(&mut self) {
self.finish();
}
}
pub(crate) async fn send_events(
tx: &mpsc::Sender<Result<Event, std::convert::Infallible>>,
events: &[anthropic::StreamEvent],
) -> bool {
for ev in events {
match crate::server::sse::stream_event_to_sse(ev) {
Ok(sse) => {
if tx.send(Ok(sse)).await.is_err() {
return false;
}
}
Err(e) => {
tracing::warn!("failed to serialize stream event: {e}");
}
}
}
true
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum StreamOutcome {
Completed,
ClientDisconnected,
UpstreamError,
}
impl StreamOutcome {
pub(crate) fn record(&self, metrics: &Metrics) -> (u16, Option<String>) {
match self {
Self::Completed => {
metrics.record_success();
metrics.record_stream_completed();
(200, None)
}
Self::ClientDisconnected => {
metrics.record_stream_client_disconnected();
(499, Some("client disconnected".into()))
}
Self::UpstreamError => {
metrics.record_stream_failed();
(502, Some("stream interrupted".into()))
}
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct AnthropicStreamUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
}
impl AnthropicStreamUsage {
pub(crate) fn observe_data(&mut self, data: &str) {
if data == "[DONE]" {
return;
}
let Ok(event) = serde_json::from_str::<anthropic::StreamEvent>(data) else {
return;
};
match event {
anthropic::StreamEvent::MessageStart { message } => {
self.input_tokens = Some(message.usage.input_tokens as u64);
}
anthropic::StreamEvent::MessageDelta {
usage: Some(usage), ..
} => {
self.output_tokens = Some(usage.output_tokens as u64);
}
_ => {}
}
}
pub(crate) fn tokens(&self) -> Option<(u64, u64)> {
match (self.input_tokens, self.output_tokens) {
(Some(input), Some(output)) => Some((input, output)),
(Some(input), None) => Some((input, 0)),
(None, Some(output)) => Some((0, output)),
(None, None) => None,
}
}
}
pub(crate) fn observe_anthropic_sse_frames(
buffer: &mut BytesMut,
search_from: &mut usize,
usage: &mut AnthropicStreamUsage,
mut recorder: Option<&mut crate::thinking_repair::ThinkingRecorder>,
ready: &mut Vec<(String, Vec<anthropic::ContentBlock>)>,
) {
while let Some((pos, delim_len)) = find_double_newline(buffer, *search_from) {
if let Ok(frame_str) = std::str::from_utf8(&buffer[..pos]) {
for line in frame_str.lines() {
let line = line.trim();
if let Some(json_str) = line.strip_prefix("data: ") {
usage.observe_data(json_str);
if let Some(rec) = recorder.as_deref_mut() {
if let Some(done) = rec.observe_json(json_str) {
ready.push(done);
}
}
}
}
}
let _ = buffer.split_to(pos + delim_len);
*search_from = 0;
}
*search_from = buffer.len().saturating_sub(3);
}
pub(crate) async fn read_sse_frames<F>(
response: reqwest::Response,
tx: &mpsc::Sender<Result<Event, std::convert::Infallible>>,
metrics: &Metrics,
mut on_data: F,
) -> StreamOutcome
where
F: FnMut(&str) -> Option<Vec<anthropic::StreamEvent>>,
{
use futures::StreamExt;
let mut stream = response.bytes_stream();
let mut buffer = SseFrameBuffer::new();
let mut frame_events: Vec<anthropic::StreamEvent> = Vec::new();
while let Some(chunk_result) = stream.next().await {
let bytes = match chunk_result {
Ok(b) => b,
Err(e) => {
tracing::error!("stream read error: {e}");
metrics.record_error();
return StreamOutcome::UpstreamError;
}
};
let frames = match buffer.push(&bytes) {
Ok(frames) => frames,
Err(e) => {
tracing::error!(error = %e, "SSE buffer exceeded maximum size, aborting stream");
metrics.record_error();
return StreamOutcome::UpstreamError;
}
};
for frame in frames {
frame_events.clear();
match std::str::from_utf8(&frame) {
Ok(frame_str) => {
for line in frame_str.lines() {
let line = line.trim();
if let Some(json_str) = line.strip_prefix("data: ") {
if let Some(mut events) = on_data(json_str) {
frame_events.append(&mut events);
}
}
}
}
Err(e) => {
tracing::warn!("skipping non-UTF-8 SSE frame: {e}");
}
}
if !send_events(tx, &frame_events).await {
tracing::debug!("client disconnected during stream");
return StreamOutcome::ClientDisconnected;
}
}
}
StreamOutcome::Completed
}
+11
View File
@@ -0,0 +1,11 @@
pub(crate) mod handler;
pub(crate) mod helpers;
#[cfg(test)]
mod tests;
pub(crate) use handler::messages_stream;
pub(crate) use helpers::{
observe_anthropic_sse_frames, read_sse_frames, send_events, AnthropicStreamUsage,
StreamDeploymentAccounting, StreamOutcome,
};
+5 -2
View File
@@ -1,4 +1,7 @@
use super::*;
use crate::server::streaming::{observe_anthropic_sse_frames, AnthropicStreamUsage};
use crate::thinking_repair::ThinkingRecorder;
use anyllm_translate::anthropic;
use bytes::BytesMut;
#[test]
fn anthropic_stream_usage_tracks_complete_sse_frames() {
@@ -26,7 +29,7 @@ data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinki
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_1\"}}\n\n\
data: {\"type\":\"message_stop\"}\n\n"[..]);
let mut search_from = 0;
let mut recorder = crate::thinking_repair::ThinkingRecorder::new();
let mut recorder = ThinkingRecorder::new();
let mut ready = Vec::new();
observe_anthropic_sse_frames(
+180
View File
@@ -88,6 +88,186 @@ fn count_segment(text: &str) -> usize {
TOKENIZER.encode_with_special_tokens(text).len()
}
#[cfg(test)]
mod tests {
use super::*;
use anthropic::messages::{ContentBlock, InputMessage, Tool, ToolResultContent};
use anthropic::{Role, System, SystemBlock};
use anyllm_translate::anthropic;
fn req(messages: Vec<InputMessage>) -> anthropic::MessageCreateRequest {
anthropic::MessageCreateRequest {
model: "claude-sonnet-4-6".into(),
max_tokens: 100,
messages,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: None,
extra: serde_json::Map::new(),
}
}
fn user_text(text: &str) -> InputMessage {
InputMessage {
role: Role::User,
content: anthropic::Content::Text(text.into()),
}
}
fn assistant_blocks(blocks: Vec<ContentBlock>) -> InputMessage {
InputMessage {
role: Role::Assistant,
content: anthropic::Content::Blocks(blocks),
}
}
#[test]
fn empty_messages_count_zero() {
assert_eq!(count_request_tokens_sync(&req(vec![])), 0);
}
#[test]
fn simple_text_message_counted() {
let count = count_request_tokens_sync(&req(vec![user_text("Hello, world!")]));
assert!(count > 0 && count < 20, "got {count}");
}
#[test]
fn system_text_adds_tokens() {
let mut r = req(vec![user_text("What is the capital of France?")]);
r.system = Some(System::Text("You are a helpful assistant.".into()));
assert!(count_request_tokens_sync(&r) > 5);
}
#[test]
fn system_blocks_are_counted() {
let mut r = req(vec![user_text("hi")]);
r.system = Some(System::Blocks(vec![
SystemBlock {
block_type: "text".into(),
text: "Block one.".into(),
cache_control: None,
},
SystemBlock {
block_type: "text".into(),
text: "Block two.".into(),
cache_control: None,
},
]));
assert!(count_request_tokens_sync(&r) > 5);
}
#[test]
fn tool_definitions_add_tokens() {
let mut r = req(vec![user_text("Use a tool")]);
r.tools = Some(vec![Tool {
name: "get_weather".into(),
description: Some("Get weather".into()),
input_schema: serde_json::json!({
"type": "object",
"properties": { "location": { "type": "string" } },
"required": ["location"]
}),
}]);
assert!(count_request_tokens_sync(&r) > 10);
}
#[test]
fn tool_without_description_still_counts() {
let mut r = req(vec![user_text("Use a tool")]);
r.tools = Some(vec![Tool {
name: "get_time".into(),
description: None,
input_schema: serde_json::json!({ "type": "object" }),
}]);
assert!(count_request_tokens_sync(&r) > 3);
}
#[test]
fn thinking_block_in_content_counted() {
let r = req(vec![
user_text("Think step by step"),
assistant_blocks(vec![
ContentBlock::Thinking {
thinking: "Let me work through this carefully...".into(),
signature: Some("sig_abc".into()),
},
ContentBlock::Text {
text: "Here is the answer.".into(),
},
]),
]);
assert!(count_request_tokens_sync(&r) > 8);
}
#[test]
fn tool_result_with_error_prefixed() {
let r = req(vec![
user_text("Run the tool"),
InputMessage {
role: Role::Assistant,
content: anthropic::Content::Blocks(vec![ContentBlock::ToolUse {
id: "tu_001".into(),
name: "search".into(),
input: serde_json::json!({"query": "test"}),
}]),
},
InputMessage {
role: Role::User,
content: anthropic::Content::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu_001".into(),
content: Some(ToolResultContent::Text("Error: permission denied".into())),
is_error: Some(true),
}]),
},
]);
assert!(count_request_tokens_sync(&r) > 5);
}
#[test]
fn multiple_messages_accumulate_tokens() {
let single = count_request_tokens_sync(&req(vec![user_text("hello")]));
let r = req(vec![
user_text("hello"),
InputMessage {
role: Role::Assistant,
content: anthropic::Content::Text("world".into()),
},
user_text("foo bar baz"),
]);
let multi = count_request_tokens_sync(&r);
assert!(
multi > single,
"multiple messages ({multi}) should have more tokens than one ({single})"
);
}
#[test]
fn image_block_does_not_crash() {
let r = req(vec![assistant_blocks(vec![
ContentBlock::Image {
source: anthropic::messages::ImageSource {
source_type: "base64".into(),
media_type: Some("image/png".into()),
data: Some("AAAA".into()),
url: None,
},
},
ContentBlock::Text {
text: "Here is the image.".into(),
},
])]);
assert!(count_request_tokens_sync(&r) > 0);
}
}
fn count_content(content: &anthropic::Content) -> usize {
match content {
anthropic::Content::Text(t) => count_segment(t),
+566
View File
@@ -761,3 +761,569 @@ async fn chat_completions_returns_openai_error_format() {
assert!(body["error"]["type"].is_string());
assert!(body["error"]["message"].is_string());
}
// --- Anthropic-specific parity tests ---
// Ported from LiteLLM test patterns:
// pass_through_unit_tests/test_anthropic_messages_passthrough.py
// pass_through_unit_tests/base_anthropic_messages_prompt_caching_test.py
#[tokio::test]
async fn anthropic_test_thinking_param_passthrough() {
// LiteLLM: test_anthropic_messages_with_thinking
// Verify thinking.budget_tokens reaches the backend via /v1/messages passthrough.
let captured_body: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
let captured_headers: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(captured_body.clone(), captured_headers.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100,
"thinking": {"budget_tokens": 100}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let sent = captured_body.lock().unwrap().take().unwrap();
assert_eq!(
sent["thinking"]["budget_tokens"], 100,
"thinking.budget_tokens should be preserved in passthrough: {sent}"
);
}
#[tokio::test]
async fn anthropic_test_extra_headers_passthrough() {
// LiteLLM: test_anthropic_messages_with_extra_headers
// Verify custom headers like anthropic-version reach the backend.
let captured_body: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
let captured_headers: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(captured_body.clone(), captured_headers.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.header("anthropic-version", "2023-06-01")
.json(&json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let headers = captured_headers.lock().unwrap();
assert!(
headers.iter().any(|(k, _)| k == "anthropic-version"),
"anthropic-version should be forwarded: {:?}",
*headers
);
}
#[tokio::test]
async fn anthropic_test_cache_control_passthrough() {
// LiteLLM: base_anthropic_messages_prompt_caching_test (adapted)
// Verify cache_control breakpoints pass through the anthropic passthrough.
let captured_body: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
let captured_headers: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(captured_body.clone(), captured_headers.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "claude-sonnet-4-6",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "Long context to cache.", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Follow up."}
]}
],
"max_tokens": 100
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let sent = captured_body.lock().unwrap().take().unwrap();
let blocks = sent["messages"][0]["content"].as_array().unwrap();
assert_eq!(
blocks[0]["cache_control"]["type"], "ephemeral",
"cache_control should be preserved: {blocks:#?}"
);
}
#[tokio::test]
async fn anthropic_test_streaming_error_handling() {
// LiteLLM: test_anthropic_messages_streaming_with_bad_request
// Verify a streaming error from the Anthropic backend returns SSE content.
let app = axum::Router::new().route(
"/v1/messages",
axum::routing::post(|| async {
(
[("content-type", "text/event-stream")],
concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_err\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
"event: error\n",
"data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"max_tokens: must be at least 1\"}}\n\n",
)
)
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mock = format!("http://{}", addr);
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
"stream": true
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let text = resp.text().await.unwrap();
assert!(
text.contains("finish_reason") || text.contains("[DONE]") || text.contains("error"),
"response should contain streaming content: {text:.200}"
);
}
// --- OpenAI-specific parity tests ---
// Ported from LiteLLM test patterns:
// proxy_unit_tests/test_unit_test_streaming.py
// openai_endpoints_tests/test_e2e_openai_responses_api.py
// llm_translation/test_openai.py
#[tokio::test]
async fn openai_chat_completions_missing_model_returns_error() {
let mock = spawn_mock_chat_backend().await;
let proxy = spawn_proxy(openai_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]["type"].is_string(), "should have error type");
}
#[tokio::test]
async fn openai_chat_completions_missing_messages_returns_error() {
let mock = spawn_mock_chat_backend().await;
let proxy = spawn_proxy(openai_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o",
"max_tokens": 100
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]["type"].is_string());
}
#[tokio::test]
async fn openai_chat_completions_invalid_json_returns_openai_error() {
let mock = spawn_mock_chat_backend().await;
let proxy = spawn_proxy(openai_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.body("{invalid json}")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]["type"].is_string());
assert!(body["error"]["message"].is_string());
}
// --- OpenAI Responses API parity tests ---
// Ported from LiteLLM test patterns:
// openai_endpoints_tests/test_e2e_openai_responses_api.py
/// Build a Config targeting the Responses API backend format with a mock base URL.
fn responses_config_with_base(base_url: &str) -> Config {
Config {
backend: config::BackendKind::OpenAI,
openai_api_key: "test-key".to_string(),
openai_base_url: base_url.to_string(),
listen_port: 0,
model_mapping: config::ModelMapping {
big_model: "gpt-4o-mini".into(),
small_model: "gpt-4o-mini".into(),
},
tls: config::TlsConfig::default(),
backend_auth: config::BackendAuth::BearerToken("test-key".into()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
expose_degradation_warnings: false,
openai_api_format: config::OpenAIApiFormat::Responses,
provider_id: None,
}
}
async fn spawn_mock_responses_backend() -> String {
let app = axum::Router::new().route(
"/v1/responses",
axum::routing::post(|| async {
axum::Json(serde_json::json!({
"id": "resp_mock123",
"type": "response",
"model": "gpt-4o-mini",
"output": [{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello from Responses API mock!"}]
}],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
"status": "completed"
}))
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
#[tokio::test]
async fn responses_api_non_streaming() {
// LiteLLM: test_basic_response - verify Responses API translates correctly
let mock = spawn_mock_responses_backend().await;
std::env::set_var("PROXY_OPEN_RELAY", "true");
let app = routes::app(responses_config_with_base(&mock));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let proxy = format!("http://{addr}");
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o-mini",
"max_tokens": 50,
"messages": [{"role": "user", "content": "Say hello"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["type"], "message", "type should be 'message'");
assert_eq!(body["role"], "assistant", "role should be 'assistant'");
assert_eq!(body["content"][0]["type"], "text");
assert_eq!(body["content"][0]["text"], "Hello from Responses API mock!");
}
#[tokio::test]
async fn responses_api_bad_request_error() {
// LiteLLM: test_bad_request_error - verify invalid params return proper error
let mock = spawn_mock_responses_backend().await;
std::env::set_var("PROXY_OPEN_RELAY", "true");
let app = routes::app(responses_config_with_base(&mock));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let proxy = format!("http://{addr}");
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o-mini",
"max_tokens": 50,
"messages": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["content"][0]["text"], "Hello from Responses API mock!");
}
#[tokio::test]
async fn responses_api_messages_passthrough_preserves_content() {
// LiteLLM: test_basic_response - verify message content round-trips
let mock = spawn_mock_responses_backend().await;
std::env::set_var("PROXY_OPEN_RELAY", "true");
let app = routes::app(responses_config_with_base(&mock));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let proxy = format!("http://{addr}");
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Tell me a joke"}],
"max_tokens": 100
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(
body["content"].as_array().map_or(false, |c| !c.is_empty()),
"content should be non-empty"
);
assert_eq!(body["content"][0]["type"], "text");
assert!(
body["usage"]["input_tokens"].as_u64().unwrap_or(0) > 0,
"input_tokens should be positive"
);
}
#[tokio::test]
async fn responses_api_backend_error_returns_proper_error() {
// LiteLLM: test_bad_request_error - backend returns 400
let app = axum::Router::new().route(
"/v1/responses",
axum::routing::post(|| async {
(
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({
"error": {"message": "Invalid model", "type": "invalid_request_error"}
})),
)
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mock = format!("http://{addr}");
std::env::set_var("PROXY_OPEN_RELAY", "true");
let app = routes::app(responses_config_with_base(&mock));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let proxy = format!("http://{addr}");
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "nonexistent-model",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 50
}))
.send()
.await
.unwrap();
let status = resp.status().as_u16();
assert!(status >= 400, "expected error status, got {status}");
}
#[tokio::test]
async fn openai_chat_completions_streaming_finish_reason_stop() {
// Verify streaming chat completions end with finish_reason: stop
// and data: [DONE] using OpenAI-style SSE.
let app = axum::Router::new().route(
"/v1/chat/completions",
axum::routing::post(|| async {
let body = concat!(
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
([("content-type", "text/event-stream")], body)
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mock = format!("http://{}", addr);
let proxy = spawn_proxy(openai_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100,
"stream": true
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let text = resp.text().await.unwrap();
assert!(
text.contains("\"finish_reason\":\"stop\""),
"should contain stop reason: {text:.200}"
);
assert!(
text.contains("[DONE]"),
"should contain [DONE] sentinel: {text:.200}"
);
assert!(
text.contains("Hello"),
"should contain streamed text: {text:.200}"
);
assert!(
text.contains("world"),
"should contain all streamed text: {text:.200}"
);
}
#[tokio::test]
async fn openai_chat_completions_streaming_backend_error_returns_sse_error() {
// LiteLLM: test_unit_test_streaming.py pattern
// Backend returns a 500 HTTP error for a streaming request.
// The proxy should return an appropriate error.
let app = axum::Router::new().route(
"/v1/chat/completions",
axum::routing::post(|| async {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
)
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mock = format!("http://{}", addr);
let proxy = spawn_proxy(openai_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
"stream": true
}))
.send()
.await
.unwrap();
// Should return an error (either 502 from proxy or pass through the 500)
assert!(
resp.status().as_u16() >= 400,
"expected error status, got {}",
resp.status()
);
}
#[tokio::test]
async fn openai_chat_completions_tool_call_streaming() {
// LiteLLM: test_openai.py tool calling pattern
// Verify streaming with tool calls works end-to-end.
let app = axum::Router::new().route(
"/v1/chat/completions",
axum::routing::post(|| async {
let body = concat!(
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"loc\\\":\\\"NYC\\\"}\"}}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
);
([("content-type", "text/event-stream")], body)
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mock = format!("http://{}", addr);
let proxy = spawn_proxy(openai_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/chat/completions"))
.header("x-api-key", "test")
.header("content-type", "application/json")
.json(&json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "What is the weather?"}],
"max_tokens": 100,
"stream": true,
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {"loc": {"type": "string"}}, "required": ["loc"]}
}
}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let text = resp.text().await.unwrap();
assert!(
text.contains("tool_calls"),
"should contain tool calls: {text:.200}"
);
assert!(
text.contains("\"finish_reason\":\"tool_calls\""),
"should have tool_calls finish: {text:.200}"
);
}
+203
View File
@@ -1,3 +1,10 @@
use anyllm_translate::anthropic::ErrorType;
use anyllm_translate::mapping::errors_map::{
anthropic_error_type_to_status, classify_chunk_error_code, create_anthropic_error,
openai_status_to_anthropic_error_type, openai_to_anthropic_error, status_to_anthropic_error,
};
use anyllm_translate::openai;
#[test]
fn malformed_openai_response_fails_deserialization() {
let json = include_str!("../../../fixtures/openai/chat_completion_malformed.json");
@@ -7,3 +14,199 @@ fn malformed_openai_response_fails_deserialization() {
"malformed response should fail deserialization"
);
}
// --- Fixture-based error translation tests ---
#[test]
fn fixture_openai_401_translates_to_anthropic_auth() {
let json = include_str!("../../../fixtures/openai/error_401.json");
let openai_err: openai::errors::ErrorResponse = serde_json::from_str(json).unwrap();
let anthropic_err = openai_to_anthropic_error(&openai_err, 401, Some("req_test".into()));
assert_eq!(
anthropic_err.error.error_type,
ErrorType::AuthenticationError
);
assert!(anthropic_err.error.message.contains("Incorrect API key"));
assert_eq!(anthropic_err.request_id.unwrap(), "req_test");
}
#[test]
fn fixture_openai_429_translates_to_anthropic_rate_limit() {
let json = include_str!("../../../fixtures/openai/error_429.json");
let openai_err: openai::errors::ErrorResponse = serde_json::from_str(json).unwrap();
let anthropic_err = openai_to_anthropic_error(&openai_err, 429, None);
assert_eq!(anthropic_err.error.error_type, ErrorType::RateLimitError);
assert!(anthropic_err.error.message.contains("Rate limit"));
assert!(anthropic_err.request_id.is_none());
}
#[test]
fn fixture_openai_500_translates_to_anthropic_api_error() {
let json = include_str!("../../../fixtures/openai/error_500.json");
let openai_err: openai::errors::ErrorResponse = serde_json::from_str(json).unwrap();
let anthropic_err = openai_to_anthropic_error(&openai_err, 500, None);
assert_eq!(anthropic_err.error.error_type, ErrorType::ApiError);
}
#[test]
fn fixture_anthropic_invalid_request_deserializes() {
let json = include_str!("../../../fixtures/anthropic/error_invalid_request.json");
let err: anyllm_translate::anthropic::errors::ErrorResponse =
serde_json::from_str(json).unwrap();
assert_eq!(err.error.error_type, ErrorType::InvalidRequestError);
assert_eq!(err.response_type, "error");
}
#[test]
fn fixture_anthropic_rate_limit_deserializes() {
let json = include_str!("../../../fixtures/anthropic/error_rate_limit.json");
let err: anyllm_translate::anthropic::errors::ErrorResponse =
serde_json::from_str(json).unwrap();
assert_eq!(err.error.error_type, ErrorType::RateLimitError);
assert_eq!(err.request_id.unwrap(), "req_01XYZ");
}
// --- Status-to-error mapping tests ---
#[test]
fn status_mapping_coverage_all_anthropic_types() {
let types = [
ErrorType::InvalidRequestError,
ErrorType::AuthenticationError,
ErrorType::BillingError,
ErrorType::PermissionError,
ErrorType::NotFoundError,
ErrorType::RequestTooLarge,
ErrorType::RateLimitError,
ErrorType::ApiError,
ErrorType::TimeoutError,
ErrorType::OverloadedError,
];
for t in &types {
let status = anthropic_error_type_to_status(t);
let back = openai_status_to_anthropic_error_type(status);
assert_eq!(&back, t, "round-trip failed for {t:?}");
}
}
#[test]
fn status_to_anthropic_error_with_and_without_request_id() {
let with_id = status_to_anthropic_error(429, "Too fast", Some("req_abc".into()));
assert_eq!(with_id.error.error_type, ErrorType::RateLimitError);
assert_eq!(with_id.request_id.unwrap(), "req_abc");
let without_id = status_to_anthropic_error(500, "Server error", None);
assert!(without_id.request_id.is_none());
}
#[test]
fn create_anthropic_error_preserves_all_fields() {
let err = create_anthropic_error(
ErrorType::NotFoundError,
"Model not found".into(),
Some("req_xyz".into()),
);
assert_eq!(err.response_type, "error");
assert_eq!(err.error.error_type, ErrorType::NotFoundError);
assert_eq!(err.error.message, "Model not found");
assert_eq!(err.request_id.unwrap(), "req_xyz");
}
// --- Chunk error code classification tests ---
#[test]
fn classify_chunk_numeric_code_400_599_is_mapped() {
assert_eq!(
classify_chunk_error_code(Some(&serde_json::json!(401))),
ErrorType::AuthenticationError
);
}
#[test]
fn classify_chunk_numeric_code_out_of_range_is_api_error() {
assert_eq!(
classify_chunk_error_code(Some(&serde_json::json!(600))),
ErrorType::ApiError
);
}
#[test]
fn classify_chunk_string_anthropic_wire_string_is_recovered() {
assert_eq!(
classify_chunk_error_code(Some(&serde_json::json!("overloaded_error"))),
ErrorType::OverloadedError
);
}
#[test]
fn classify_chunk_string_unknown_is_api_error() {
assert_eq!(
classify_chunk_error_code(Some(&serde_json::json!("some_error"))),
ErrorType::ApiError
);
}
#[test]
fn classify_chunk_none_is_api_error() {
assert_eq!(classify_chunk_error_code(None), ErrorType::ApiError);
}
// --- Status boundary tests ---
#[test]
fn status_408_and_504_both_map_to_timeout() {
assert_eq!(
openai_status_to_anthropic_error_type(408),
ErrorType::TimeoutError
);
assert_eq!(
openai_status_to_anthropic_error_type(504),
ErrorType::TimeoutError
);
}
#[test]
fn status_503_and_529_both_map_to_overloaded() {
assert_eq!(
openai_status_to_anthropic_error_type(503),
ErrorType::OverloadedError
);
assert_eq!(
openai_status_to_anthropic_error_type(529),
ErrorType::OverloadedError
);
}
// --- Provider-specific error format tests ---
#[test]
fn azure_auth_error_maps_to_anthropic_auth() {
// Azure OpenAI returns 401 with a specific message format
let err = openai::errors::ErrorResponse {
error: openai::errors::ErrorDetail {
message: "Access denied due to invalid subscription key or wrong API endpoint.".into(),
error_type: "access_denied".into(),
param: None,
code: Some("401".into()),
},
};
let result = openai_to_anthropic_error(&err, 401, None);
assert_eq!(result.error.error_type, ErrorType::AuthenticationError);
}
#[test]
fn openai_context_window_error_maps_to_invalid_request() {
// OpenAI returns 400 when context length is exceeded
let err = openai::errors::ErrorResponse {
error: openai::errors::ErrorDetail {
message:
"This model's maximum context length is 128000 tokens. You requested 130000 tokens."
.into(),
error_type: "invalid_request_error".into(),
param: None,
code: Some("context_length_exceeded".into()),
},
};
let result = openai_to_anthropic_error(&err, 400, None);
assert_eq!(result.error.error_type, ErrorType::InvalidRequestError);
}
@@ -992,3 +992,95 @@ fn finish_reason_error_without_object_emits_error_event() {
}
assert!(translator.finish().is_empty());
}
#[test]
fn reasoning_content_then_tool_calls_creates_thinking_then_tool_use() {
let mut translator = StreamingTranslator::new("deepseek-reasoner".into());
translator.process_chunk(&reasoning_chunk(
"c1",
"deepseek-reasoner",
"Let me calculate",
));
let chunks = vec![
ChatCompletionChunk {
id: "c1".into(),
object: "chat.completion.chunk".into(),
model: "deepseek-reasoner".into(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content: None,
refusal: None,
tool_calls: Some(vec![ChunkToolCall {
index: 0,
id: Some("call_1".into()),
function: Some(ChunkFunctionCall {
name: Some("get_weather".into()),
arguments: Some("{\"loc\":\"NYC\"}".into()),
}),
call_type: Some("function".into()),
}]),
reasoning_content: None,
},
finish_reason: None,
logprobs: None,
}],
usage: None,
created: None,
system_fingerprint: None,
error: None,
},
finish_chunk("c1", "deepseek-reasoner", openai::FinishReason::ToolCalls),
];
let mut all_events = Vec::new();
for chunk in &chunks {
all_events.extend(translator.process_chunk(chunk));
}
let tool_use_started = all_events.iter().any(|e| {
matches!(
e,
anthropic::StreamEvent::ContentBlockStart { index: 0, content_block }
if matches!(content_block, anthropic::ContentBlock::ToolUse { name, .. } if name == "get_weather")
)
});
assert!(
tool_use_started,
"tool_use block should start after reasoning: {all_events:?}"
);
let args_delta = all_events.iter().any(|e| {
matches!(
e,
anthropic::StreamEvent::ContentBlockDelta { index: 0, delta }
if matches!(delta, anthropic::Delta::InputJsonDelta { .. })
)
});
assert!(args_delta, "tool call args should arrive as JSON delta");
}
#[test]
fn multiple_reasoning_chunks_merge_into_single_thinking_block() {
let mut translator = StreamingTranslator::new("deepseek-reasoner".into());
let events = translator.process_chunk(&reasoning_chunk("c1", "deepseek-reasoner", "Step 1: "));
assert_eq!(events.len(), 3);
let events = translator.process_chunk(&reasoning_chunk("c1", "deepseek-reasoner", "think "));
assert_eq!(events.len(), 1);
let delta = &events[0];
assert!(
matches!(delta, anthropic::StreamEvent::ContentBlockDelta { delta, .. }
if matches!(delta, anthropic::Delta::ThinkingDelta { thinking } if thinking == "think "))
);
let events = translator.process_chunk(&reasoning_chunk("c1", "deepseek-reasoner", "carefully"));
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], anthropic::StreamEvent::ContentBlockDelta { delta, .. }
if matches!(delta, anthropic::Delta::ThinkingDelta { thinking } if thinking == "carefully"))
);
}
+291
View File
@@ -0,0 +1,291 @@
# LiteLLM Test Parity for anyllm-proxy
## Overview
anyllm-proxy is a specialized **protocol translator** (Anthropic <-> OpenAI format mapping) and proxy in Rust.
LiteLLM is a broad **AI gateway** in Python with 100+ provider backends, enterprise governance, and extensive
integration testing. These are different categories; porting every test is not the goal.
| Metric | LiteLLM | anyllm-proxy |
|--------|---------|--------------|
| Language | Python | Rust |
| Test framework | pytest | built-in `#[test]`, `rstest`, `test_case` |
| Total test files | 2,291 | 189 |
| Total test functions | ~31,129 | 1,193 |
| Integration tests | VCR-recorded live tests + unit tests | Mocked unit tests + fixture-based |
| Live API tests | Extensive (VCR cassettes) | Minimal (10 ignored tests) |
## Test Category Parity
Each category lists: litellm count -> ours count, portability, and priority.
P = Port (worth doing), S = Skip (not applicable), R = Reference (useful to read but can't port 1:1)
### 1. Format Translation / Provider Mapping
These are the closest to our `translator` crate.
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `llm_translation/base_llm_unit_tests.py` | 43 | 491 | **R** | Our translator has comprehensive mapping tests. Litellm tests are Python-specific (parameter validation via `get_optional_params`) |
| `llm_translation/test_openai.py` | 30 | inline | **R** | We cover OpenAI format via `openai/chat_completions/tests.rs` and mapping tests |
| `llm_translation/test_anthropic_completion.py` | 50 | inline | **P** | Missing: thinking block handling, tool streaming edge cases, metadata handling, citation streaming |
| `llm_translation/test_gemini.py` | 37 | inline | **R** | Our Gemini mapping tests (`gemini_message_map/tests.rs`, `gemini_streaming_map/tests.rs`) cover the core translation |
| `llm_translation/test_optional_params.py` | 78 | 0 | **P** | **Gap:** We have no `get_optional_params`-style validation. Tests like `test_anthropic_optional_params` (whitespace stop sequence dropped), `test_supports_system_message`, provider-specific param validation |
| `llm_translation/test_prompt_factory.py` | 75 | 0 | **S** | Tests prompt factory functions for different providers (Claude, Bedrock, Vertex). Our architecture is different -- we use struct-level serde transforms, not string templating |
| `llm_translation/test_prompt_caching.py` | 1 | 29 | **P** | We cover caching in `proxy/src/cache/tests.rs`. Our tests focus on response caching, not prompt caching token accounting |
| `llm_translation/test_deepseek_completion.py` | ~10 | 37 | **R** | **Covered.** Our `reasoning_content` <-> thinking mapping is tested in `message_map/tests.rs`, `streaming_map/tests.rs`, `reverse_message_map/tests.rs`, and `chat_completions/tests.rs`. Includes streaming lifecycle, tool call interleaving, thinking_blocks preference over reasoning_content, redacted thinking, and effective_text fallback. |
| `llm_translation/test_cohere.py` | ~30 | 0 | **S** | Cohere-specific translate tests. We don't target Cohere as a primary format; OpenAI-compat via OPENAI_BASE_URL |
| `llm_translation/test_groq.py` | ~10 | 0 | **S** | Groq-specific. Works via OPENAI_BASE_URL |
| `llm_translation/test_together_ai.py` | 1 | 0 | **S** | Provider-specific |
| `llm_translation/test_fireworks_ai_translation.py` | 8 | 0 | **S** | Provider-specific |
**Key gap:** We lack structured tests for optional parameter validation per provider. We rely on `#[serde(skip_serializing_if = "Option::is_none")]` which silently drops unsupported params -- that's by design, but we should verify behavior.
### 2. Proxy Server / Routes
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `proxy_unit_tests/test_proxy_server.py` | 51 | inline | **R** | Our proxy handler tests are spread across `server/routes/tests.rs`, `server/chat_completions/extensions/tests.rs`, and integration tests |
| `proxy_unit_tests/test_proxy_utils.py` | 69 | 0 | **R** | Tests address LiteLLM-specific utilities (Prisma client, metadata forwarding). Our architecture is Rust-native; not directly portable |
| `proxy_unit_tests/test_proxy_routes.py` | 11 | 5 | **P** | Route dispatch tests (`test_is_llm_api_route`, `test_anthropic_api_routes`, `test_get_request_route_*`). We have route dispatch in `server/routes/tests.rs` (4 tests) and `tests/route_dispatch.rs` (1 test). Still lacks edge case tests for URL parsing with base URLs, path injection, query params |
| `proxy_unit_tests/test_proxy_token_counter.py` | 24 | 14 | **P** | **Gap (narrowing):** Token counting tests. We now have 10 unit tests for `count_request_tokens_sync` (covering empty messages, system prompts, tool definitions, thinking blocks, tool results, multiple messages, image blocks) plus 4 integration tests in `compatibility.rs` (basic count, empty messages, tools, invalid body). Still missing provider-specific counting (GPT, vLLM, Vertex). |
| `proxy_unit_tests/test_proxy_exception_mapping.py` | 7 | 20 | **R** | **Covered.** Our `error_fixtures.rs` has 18 tests covering OpenAI-to-Anthropic translation for all major HTTP codes, chunk error classification, status boundaries, Azure auth, and context window exceeded. Plus 2 error-validation tests in `chat_completions.rs` for missing model/messages params. |
| `proxy_unit_tests/test_auth_checks.py` | ~40 | 30 | **R** | Our auth tests cover key generation, validation, and RBAC. Litellm tests cover end-user budget, model access control, team access. Different feature sets |
| `proxy_unit_tests/test_jwt.py` | ~20 | 0 | **S** | JWT auth is LiteLLM-specific; we support OIDC/JWT via separate mechanism |
| `proxy_unit_tests/test_proxy_config_unit_test.py` | 9 | 147 | **R** | Our config tests are more comprehensive (simple, multi, litellm, model_router formats). Litellm tests focus on file reading and OS env vars |
| `proxy_unit_tests/test_google_endpoint_routing.py` | 1 | 0 | **S** | Google-specific routing |
| `proxy_unit_tests/test_request_size_limit_middleware.py` | 3 | 0 | **S** | Request size limiting middleware -- not relevant to our architecture |
| `proxy_unit_tests/test_proxy_server_caching.py` | 1 | 29 | **R** | We cover caching more thoroughly |
### 3. Passthrough / Anthropic Messages
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `pass_through_unit_tests/test_anthropic_messages_passthrough.py` | 14 | 17 | **P** | **Gap:** Tests for Anthropic passthrough with streaming, bad requests, fallbacks, metadata tracking, extra headers, thinking blocks, Bedrock credential passthrough. We have `tests/compatibility.rs` for Anthropic endpoints but minimal coverage |
| `pass_through_unit_tests/base_anthropic_messages_prompt_caching_test.py` | 6 | 0 | **P** | **Gap:** Prompt caching token accounting tests (cache creation tokens, cache read tokens, streaming caching) |
| `pass_through_unit_tests/base_anthropic_unified_messages_test.py` | 4 | 0 | **P** | **Gap:** Unified message format tests (non-streaming, streaming, response format consistency) |
| `pass_through_unit_tests/test_passthrough_managed_ids.py` | 109 | 0 | **S** | Managed ID encoding/decoding system specific to LiteLLM's passthrough router. Not relevant to our architecture |
| `pass_through_tests/test_anthropic_passthrough.py` | 4 | 0 | **R** | Cost injection tests for Anthropic streaming |
| `pass_through_unit_tests/test_unit_test_anthropic_pass_through.py` | ~12 | 0 | **P** | Anthropic passthrough edge cases |
### 4. Streaming
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `local_testing/test_streaming.py` | 57 | 68 | **R** | Provider-specific streaming tests (Cohere, Azure, Gemini, Mistral, Bedrock, Ollama, Replicate, Vertex). We have generic streaming tests in `streaming_map/tests.rs` plus reasoning + tool call interleaving edge cases. |
| `proxy_unit_tests/test_unit_test_streaming.py` | 4 | 6 | **R** | **Covered.** Streaming passthrough test patterns: finish_reason stop streaming, backend HTTP error during streaming, tool call streaming in `chat_completions.rs`. |
### 5. OpenAI Endpoints Compliance
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `openai_endpoints_tests/test_openai_batches_endpoint.py` | 8 | 46 | **R** | **Covered.** Batch operations: create, list, cancel, status sync, VK isolation, invalid JSONL. Our `tests/batch_api.rs` has 8 proxy integration tests plus 38 batch engine unit tests covering queue operations, file storage, job lifecycle, and validation. |
| `openai_endpoints_tests/test_openai_files_endpoints.py` | 2 | 0 | **S** | File upload endpoints -- we delegate file handling to backend |
| `openai_endpoints_tests/test_openai_fine_tuning.py` | 1 | 0 | **S** | Fine tuning passthrough -- not supported |
| `openai_endpoints_tests/test_e2e_openai_responses_api.py` | 8 | 6 | **R** | **Covered.** Responses API mock tests: non-streaming, message content preservation, error handling. Plus 2 ignored live tests for real API verification. |
### 6. Batch Processing
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `batches_tests/` (9 files, 45 tests) | 45 | 38 | **R** | We cover batch in `batch_engine/src/queue/sqlite/tests.rs` and `proxy/tests/batch_api.rs`. Litellm adds: custom pricing, bedrock batch, hosted vLLM batch, rate limits, logging |
| `batches_tests/test_batch_custom_pricing.py` | ~5 | 0 | **P** | Custom pricing for batch jobs |
| `batches_tests/test_batch_rate_limits.py` | ~8 | 0 | **S** | Rate limit specific tests -- not directly ported |
### 7. Router / Load Balancing
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `router_unit_tests/` (19 files, 236 tests) | 236 | 0 | **S** | Extensive router tests: helper utils, budget limiter, cooldown handling, cost calculator, failovers, pattern matching, retries. These are Python-specific and deeply coupled to LiteLLM's Router class |
| `local_testing/test_router.py` | 87 | 0 | **S** | Core router tests |
| `local_testing/test_router_fallbacks.py` | 57 | 0 | **S** | Router fallback tests |
| `local_testing/test_router_retries.py` | 33 | 0 | **S** | Router retry tests |
### 8. Proxy Behavior / Management
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `proxy_behavior/management/` (46 files, 166 tests) | 166 | 0 | **S** | Extensive team/key/end-user management tests. LiteLLM's proxy management is more feature-rich (teams, organizations, SSO, SCIM). Our admin API is simpler |
### 9. Error Handling
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `local_testing/test_exceptions.py` | 50 | 0 | **R** | Exception handling tests. Different architecture |
| `proxy_unit_tests/test_proxy_exception_mapping.py` | 7 | 37 | **R** | **Covered.** Our `errors_map.rs` has 21 comprehensive unit tests (status mapping, error type round-trips, fixture deserialization, stream error classification). `error_fixtures.rs` now has 16 tests covering OpenAI-to-Anthropic translation for all major HTTP codes, chunk error classification, status boundaries, and edge cases. |
### 10. Logging & Observability
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `logging_callback_tests/` (35 files, 272 tests) | 272 | 0 | **S** | Callback/logging integration tests for 20+ platforms. Not directly portable; we use `tracing` + optional OTEL |
| `otel_tests/` (11 files, 41 tests) | 41 | 0 | **S** | OpenTelemetry-specific tests. Python OTel SDK tests; our OTEL is behind `--features otel` and uses `opentelemetry-rust` |
### 11. Guardrails
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `guardrails_tests/` (18 files, 189 tests) | 189 | 37 | **R** | LiteLLM has extensive guardrail integrations (Azure, OpenAI, Bedrock, custom hooks). We have `proxy/src/tools/guardrails/tests.rs` for forge-guardrails |
| `proxy_unit_tests/test_proxy_setting_guardrails.py` | 1 | 0 | **S** | Guardrail config tests |
### 12. End-to-End Tests
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `e2e/llm_translation/` (17 files, 37 tests) | 37 | 9 | **P** | **Key gap:** E2E tests for: audio speech, cache control, chat completions regression, custom pricing, DeepSeek reasoning, embeddings, image generation, messages, OCR, passthrough, rerank, responses, Vertex passthrough. We have 9 live tests (live_api.rs, live_responses.rs) |
| `e2e/llm_translation/test_passthrough_e2e.py` | 6 | 0 | **P** | Passthrough cost injection tests |
| `e2e/llm_translation/test_cache_control.py` | 2 | 0 | **P** | Cache control E2E tests |
| `e2e/llm_translation/test_deepseek_reasoning_e2e.py` | 3 | 0 | **P** | DeepSeek reasoning E2E tests |
| `proxy_e2e_anthropic_messages_tests/` (2 files) | 4 | 0 | **P** | Anthropic E2E passthrough: beta headers, Claude agent SDK |
### 13. Cost Management
| Category | LiteLLM | ours | Port? | Notes |
|----------|---------|------|-------|-------|
| `spend_tracking_tests/` (2 files, 13 tests) | 13 | 20 | **R** | We cover cost more thoroughly: model pricing DB, spend accumulation, budget enforcement |
| `proxy_unit_tests/test_proxy_server_spend.py` | 1 | 0 | **S** | Spend endpoint tests |
| `proxy_unit_tests/test_check_batch_cost.py` | 17 | 0 | **R** | Batch cost checking |
### 14. Provider-Specific (not portable)
| Category | LiteLLM | Port? | Reason |
|----------|---------|-------|--------|
| `test_litellm/llms/anthropic/` (8 files) | ~300 | **S** | Python SDK tests; we translate Anthropic to OpenAI |
| `test_litellm/llms/bedrock/` (9 files) | ~200 | **S** | Bedrock SDK tests; we have separate LLM backend |
| `test_litellm/llms/vertex_ai/` (16 files) | ~300 | **S** | Vertex-specific tests |
| `test_litellm/llms/openai/` (9 files) | ~100 | **S** | OpenAI SDK tests |
| `test_litellm/llms/azure/` (5 files) | ~100 | **S** | Azure-specific |
| `test_litellm/llms/gemini/` (5 files) | ~60 | **S** | Gemini-specific |
| `test_litellm/llms/mistral/` (2 files) | ~20 | **S** | Mistral-specific |
| `test_litellm/llms/databricks/` (5 files) | ~40 | **S** | Databricks-specific |
All provider-specific LLM SDK tests are **not portable** -- they test Python SDK wrappers for each provider.
Our architecture translates to OpenAI-compatible format and delegates to the backend. The translation
itself is tested in our translator crate.
### 15. Not Applicable (no equivalent in anyllm)
| Litellm Category | Tests | Reason |
|-----------------|-------|--------|
| `agent_tests/` | 11 | Agent SDK tests |
| `audio_tests/` | 24 | Speech/transcription -- different abstraction |
| `image_gen_tests/` | 57 | Image generation API tests |
| `mcp_tests/` | 147 | MCP server integration tests |
| `search_tests/` | 61 | Search/vector database tests |
| `vector_store_tests/` | 40 | Vector store management tests |
| `proxy_admin_ui_tests/` | 33 | Admin UI Selenium tests (we use SPA) |
| `documentation_tests/` | 3 | Doc validation tests |
| `benchmarks/` | 5 | Benchmark tests |
| `load_tests/` | 8 | Load/performance tests |
| `local_testing/test_completion_cost.py` | 187 | Cost calculation tests |
| `local_testing/test_caching.py` | 93 | Caching tests |
## Portability Summary
| Priority | Category | LiteLLM count | Port to Rust | Effort | Impact |
|----------|----------|--------------|--------------|--------|--------|
| **P0** | Anthropic passthrough E2E (streaming, thinking, caching, fallbacks) | 30 | ~12 Rust tests | Medium | High -- core protocol handling |
| **P1** | Token counting (multi-provider) | 24 | ~6 additional Rust tests | Low | Medium -- correctness |
| **P1** | Route dispatch edge cases (base URLs, injection) | 11 | ~5 Rust tests | Low | Medium -- security |
| **P2** | Prompt caching credential/token accounting | 7 | ~4 Rust tests | Low | Medium |
| **P2** | E2E cost injection for streaming | 6 | ~3 Rust tests | Low | Medium |
| **P3** | Optional param validation per provider | 78 | ~5 Rust tests | Medium | Low -- test structure diff |
| **P3** | Batch API E2E (terminal state sync, custom pricing) | 8 | ~2 Rust tests | Low | Low |
| ~~**R**~~ | Error mapping (provider error format handling) | 7 | Done (20 tests) | -- | -- |
| ~~**R**~~ | DeepSeek/Qwen reasoning content E2E | 10 | Done (37 tests) | -- | -- |
| ~~**R**~~ | Streaming test patterns | 4 | Done (6 tests) | -- | -- |
| ~~**R**~~ | OpenAI Responses API (non-live mock tests) | 8 | Done (6 mock + 2 live) | -- | -- |
## Priority Rationale
**P0 (Must port):** Core protocol behavior correctness. Litellm extensively tests Anthropic API passthrough with
streaming, thinking blocks, cache control, and error responses. These directly validate the same pipeline we
support. Missing coverage risks regressions.
**P1 (Should port):** Important correctness and error-handling areas. Token counting accuracy affects cost
tracking. Error mapping affects DX when providers return errors. Route dispatch edge cases affect security.
**P2 (Nice to port):** Streaming reliability and specific feature tests.
**P3 (Low):** Useful but lower impact. Optional param validation is structurally different in Rust
(serde-driven vs Python dict manipulation). Batch E2E needs live API key infrastructure.
## Test Patterns to Study
Some litellm test patterns worth referencing when writing new tests:
### Base class pattern for multi-provider coverage
`llm_translation/base_llm_unit_tests.py` defines `BaseLLMChatTest` with abstract tests that each
provider-specific test class inherits. Rust can approximate this with traits or test macros.
### Fixture-based golden tests
Litellm's VCR recording infrastructure (`_vcr_conftest_common.py`, `_openai_record_replay_proxy.py`)
records real API responses as fixtures. We use JSON fixture files in `fixtures/anthropic/` and
`fixtures/openai/`. Our approach is lighter weight and more deterministic, but adding live-recorded
fixtures for edge cases (e.g., real thinking blocks, real streaming sequences) would improve coverage.
### Error injection patterns
`test_anthropic_messages_passthrough.py` uses `test_anthropic_messages_streaming_with_bad_request` and
`test_anthropic_messages_router_streaming_with_bad_request` to test error handling during streaming.
We could add similar patterns using mocked HTTP handlers.
## Test Infrastructure Comparison
| Aspect | LiteLLM | anyllm-proxy |
|--------|---------|--------------|
| Mocking | `unittest.mock` (MagicMock, AsyncMock, patch) | Hand-rolled mock servers, fixture files |
| Live testing | VCR-recorded with cassette replay | `--ignored --test-threads=1` for live tests |
| Fixtures | `conftest.py` fixtures | JSON files in `fixtures/`, `#[test_case]` |
| Parameterization | `@pytest.mark.parametrize` | `rstest` + `#[test_case]` |
| Async | `pytest-asyncio` | `tokio::test` |
| Config | `conftest.py` + env vars | `test_config` helper setup |
## What We Already Cover Well
These areas have strong or sufficient coverage and do not need porting:
1. **Message format mapping** (Anthropic <-> OpenAI) -- 491 translator tests cover the core mapping
2. **Streaming state machine** -- 68 streaming tests cover chunk assembly, backpressure, finish reasons
3. **Tool calling** -- tools_map tests cover function calls, tool_choice, parallel tool calls
4. **Gemini format mapping** -- gemini_message_map + gemini_streaming_map tests
5. **Config parsing** -- 147 config tests across all formats (simple, multi, litellm, model_router)
6. **Cost tracking** -- 20 cost tests cover model pricing, budget enforcement
7. **Caching** -- 29 cache tests cover in-memory, Redis, semantic
8. **Auth** -- 30 auth tests cover key generation, RBAC, validation
9. **Virtual keys** -- integration tests for key CRUD, rate limiting
10. **Batch engine** -- 38 tests for queue operations, job lifecycle
11. **Fallback** -- 11 tests for fallback chain config, error classification, endpoint selection
12. **Thinking/repair** -- 17 tests for thinking block repair during streaming
13. **DeepSeek/Qwen reasoning content** -- ~37 tests across translator and proxy covering reasoning_content <-> thinking block mapping in both directions, streaming lifecycle, tool call interleaving, and effective_text fallback
14. **Error mapping** -- 18 error_fixtures.rs tests + 2 chat_completions validation tests covering all Anthropic error types, status mapping, fixture-based translation, stream error classification, Azure auth, and context window exceeded
15. **Token counting** -- 10 unit tests for count_request_tokens_sync + 4 integration tests covering basic counting, tools, system blocks, thinking blocks, and tool results
16. **OpenAI streaming passthrough** -- 6 tests in `chat_completions.rs` covering finish_reason stop, tool call streaming, backend HTTP errors during streaming, and malformed input validation
17. **OpenAI error format** -- Tests for missing model, missing messages, invalid JSON returning proper OpenAI error shapes
18. **OpenAI Responses API** -- 4 mock-based integration tests plus 2 ignored live tests covering non-streaming, content preservation, and error handling
## How to Run the Parity Assessment
```bash
# Run our full test suite
cargo test
# Run a specific test category
cargo test -p anyllm_translate
cargo test -p anyllm_proxy
# Run live API tests (needs API key)
OPENAI_API_KEY=sk-... cargo test --test live_api -- --ignored --test-threads=1
# Run golden fixture tests
cargo test -p anyllm_translate --test golden_fixtures
```
For litellm tests (reference only):
```bash
cd /tmp/litellm
python3 -m pytest tests/llm_translation/ -x -v --timeout 60
```