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.
82 lines
2.6 KiB
Rust
82 lines
2.6 KiB
Rust
//! Unified gateway error type + rendering into an OpenAI-compatible envelope
|
|
//! `{"error": {message, type, code, param}}` that SDK clients parse natively.
|
|
//! See docs/PROTOCOLS.md §8.5.
|
|
|
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
|
use serde_json::json;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum GatewayError {
|
|
#[error("{0}")]
|
|
InvalidRequest(String),
|
|
#[error("authentication failed: {0}")]
|
|
Unauthorized(String),
|
|
#[error("rate limit exceeded")]
|
|
RateLimited { retry_after_secs: u64 },
|
|
#[error("monthly budget exhausted (limit ${budget_usd})")]
|
|
BudgetExhausted { budget_usd: f64 },
|
|
#[error("upstream unavailable: {0}")]
|
|
UpstreamUnavailable(String),
|
|
#[error("internal error: {0}")]
|
|
Internal(String),
|
|
}
|
|
|
|
impl GatewayError {
|
|
fn parts(&self) -> (StatusCode, &'static str, &'static str) {
|
|
match self {
|
|
Self::InvalidRequest(_) => (
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid_request_error",
|
|
"invalid_request",
|
|
),
|
|
Self::Unauthorized(_) => (
|
|
StatusCode::UNAUTHORIZED,
|
|
"authentication_error",
|
|
"unauthorized",
|
|
),
|
|
Self::RateLimited { .. } => (
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"rate_limit_error",
|
|
"rate_limited",
|
|
),
|
|
Self::BudgetExhausted { .. } => (
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"insufficient_quota",
|
|
"insufficient_quota",
|
|
),
|
|
Self::UpstreamUnavailable(_) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
"upstream_unavailable",
|
|
"no_healthy_model",
|
|
),
|
|
Self::Internal(_) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal_error",
|
|
"internal",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for GatewayError {
|
|
fn into_response(self) -> axum::response::Response {
|
|
let (status, typ, code) = self.parts();
|
|
let body = Json(json!({
|
|
"error": {
|
|
"message": self.to_string(),
|
|
"type": typ,
|
|
"code": code,
|
|
"param": serde_json::Value::Null,
|
|
}
|
|
}));
|
|
let mut resp = (status, body).into_response();
|
|
if let Self::RateLimited { retry_after_secs } = self {
|
|
resp.headers_mut()
|
|
.insert("retry-after", retry_after_secs.to_string().parse().unwrap());
|
|
}
|
|
resp
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, GatewayError>;
|