cortiq-gateway 0.2.48

Universal LLM gateway with intelligent routing and an embedded multilingual admin console
//! Incoming protocol adapters. Each one builds its own axum routes and translates
//! its protocol to/from the canonical model. Enabled via flags in `[protocols]`.

pub mod anthropic_messages;
pub mod mcp;
pub mod native_passthrough;
pub mod openai_chat;
pub mod openai_completions;
pub mod openai_embeddings;
pub mod openai_models;

use crate::state::SharedState;
use axum::Router;

/// Account resolved by the API-key middleware, consumed by protocol handlers
/// for attribution (stats, budgets). Present only when keys are configured.
#[derive(Clone)]
pub struct AccountTag(pub String);

/// API-key gate for the model-facing surface. Open mode (no keys configured)
/// passes everything through — the admin console already warns about it.
/// With keys: Bearer/x-api-key must match (401), per-key rate_per_min is
/// enforced with a fixed one-minute window (429 + Retry-After), and a
/// monthly budget, when set, turns into 429 insufficient_quota once the
/// account's month-to-date spend crosses it.
async fn api_auth(
    axum::extract::State(state): axum::extract::State<SharedState>,
    mut req: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    use axum::response::IntoResponse;
    let live = state.live();
    if live.cfg.api_keys.is_empty() {
        return next.run(req).await;
    }
    let provided = req
        .headers()
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .or_else(|| req.headers().get("x-api-key").and_then(|v| v.to_str().ok()))
        .map(str::trim)
        .unwrap_or("");
    let Some(key) = live.cfg.api_keys.iter().find(|k| k.key == provided) else {
        return crate::error::GatewayError::Unauthorized("invalid or missing API key".into())
            .into_response();
    };
    if key.rate_per_min > 0 && !state.rate.allow(&key.key, key.rate_per_min) {
        return crate::error::GatewayError::RateLimited {
            retry_after_secs: state.rate.retry_after_secs(),
        }
        .into_response();
    }
    if let Some(budget) = key.monthly_budget_usd {
        if budget > 0.0 && state.stats.month_spend(&key.account) >= budget {
            return crate::error::GatewayError::BudgetExhausted { budget_usd: budget }
                .into_response();
        }
    }
    req.extensions_mut().insert(AccountTag(key.account.clone()));
    next.run(req).await
}

/// Build the protocols router. Implemented adapters are **always** mounted;
/// each handler checks the live `protocols.*` flag from config itself, so
/// toggles in the admin panel take effect without a restart (hot switching).
pub fn build_router(state: SharedState) -> Router<SharedState> {
    Router::new()
        .merge(openai_chat::routes())
        .merge(openai_completions::routes())
        .merge(openai_embeddings::routes())
        .merge(openai_models::routes())
        .merge(anthropic_messages::routes())
        .merge(mcp::routes())
        .merge(native_passthrough::routes())
        .route_layer(axum::middleware::from_fn_with_state(state, api_auth))
}