The office-readiness audit found the foundation missing: configured API keys were decorative. Any bearer — or none — passed every model endpoint, per-key rate limits existed only in the form, and all real traffic was recorded as "anonymous", leaving the per-key spend table permanently blank. A middleware on the model surface (/v1/*, /route, /mcp) now enforces keys: - no keys configured → open mode as before (the console already warns); - keys exist → Bearer or x-api-key must match (401 invalid_api_key); - per-key rate_per_min → fixed one-minute window, 429 with Retry-After; - NEW per-key monthly_budget_usd → 429 insufficient_quota once the account's month-to-date spend crosses the ceiling; - the matched account rides the request into stats — the keys table's spend columns finally see real traffic. Month-to-date spend is tracked per account in stats and persisted in the totals sidecar with a watermark (no double counting across restarts). The key dialog gains a budget field; the table shows "budget $N/mo" in red once exhausted. All verified live: 401 wrong/missing key, 200 + attribution with the right key, 429 + Retry-After over the limit, 429 insufficient_quota on the second request past a $0.000001 budget. Also: the dashboard shows the OpenRouter credit balance (GET /api/v1/credits via the stored key, cached 5 min, hidden without a key, red under $5 — live: $16.18), the server drains connections on Ctrl-C instead of dropping them, request bodies are capped at 32 MB, and docs/DEPLOY(.ru) cover office deployment: systemd/launchd/Windows service, nginx/caddy TLS, security checklist, backups. 10 strings ×7 languages.
78 lines
3.1 KiB
Rust
78 lines
3.1 KiB
Rust
//! 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))
|
|
}
|