diff --git a/README.md b/README.md
index 66881a3..1422adf 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,8 @@ docker compose exec ollama ollama pull qwen2.5:0.5b # pull a model once
See [`docker-compose.yml`](docker-compose.yml). For smart routing, set `CORTIQ_ROUTER_KEY`
and add a `[router]` (see [docs/ROUTER.md](docs/ROUTER.md)).
+Deploying for a team or an office (systemd/launchd/Windows service, TLS reverse proxy, API keys with rate limits and monthly budgets, backups): [docs/DEPLOY.md](docs/DEPLOY.md).
+
---
## 🖥️ Admin console
diff --git a/README.ru.md b/README.ru.md
index 3761ed5..03527a2 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -82,6 +82,8 @@ docker compose exec ollama ollama pull qwen2.5:0.5b # один раз скач
См. [`docker-compose.yml`](docker-compose.yml). Для умного роутинга задайте
`CORTIQ_ROUTER_KEY` и добавьте `[router]` (см. [docs/ROUTER.ru.md](docs/ROUTER.ru.md)).
+Развёртывание для команды/офиса (сервис systemd/launchd/Windows, TLS-прокси, API-ключи с лимитами и месячными бюджетами, бэкапы): [docs/DEPLOY.ru.md](docs/DEPLOY.ru.md).
+
---
## 🖥️ Панель управления
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
new file mode 100644
index 0000000..88de01e
--- /dev/null
+++ b/docs/DEPLOY.md
@@ -0,0 +1,122 @@
+# Deploying cortiq-gateway for a team / office
+
+One gateway on a small server, API keys per department, local models for the
+cheap tiers, cloud models for the hard ones. This page covers the ops side.
+
+## 1. Run as a service
+
+### Linux (systemd)
+
+```ini
+# /etc/systemd/system/cortiq-gateway.service
+[Unit]
+Description=Cortiq Gateway
+After=network-online.target
+
+[Service]
+User=cortiq
+Environment=CORTIQ_GATEWAY_HOME=/var/lib/cortiq-gateway
+ExecStart=/usr/local/bin/cortiq-gateway --no-port-search
+Restart=always
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl enable --now cortiq-gateway
+```
+
+### macOS (launchd)
+
+```xml
+
+
+
+
+ Labelcom.cortiq.gateway
+ ProgramArguments
+ /usr/local/bin/cortiq-gateway
+ --no-port-search
+
+ RunAtLoad
+ KeepAlive
+
+```
+
+```bash
+launchctl load ~/Library/LaunchAgents/com.cortiq.gateway.plist
+```
+
+### Windows
+
+Task Scheduler → Create Task → "Run whether user is logged on or not",
+trigger At startup, action `cortiq-gateway.exe --no-port-search`. Or use
+[NSSM](https://nssm.cc): `nssm install cortiq-gateway C:\bin\cortiq-gateway.exe`.
+
+### Docker
+
+```bash
+docker run -d --restart unless-stopped -p 9000:9000 \
+ -v cortiq-data:/data ghcr.io/infosave2007/cortiq-gateway:latest
+```
+
+## 2. TLS and the outside world
+
+The gateway itself speaks plain HTTP — put a reverse proxy with TLS in front
+and bind the gateway to localhost:
+
+```toml
+# gateway.toml
+listen = "127.0.0.1:9000"
+```
+
+```nginx
+server {
+ listen 443 ssl;
+ server_name ai.example.com;
+ ssl_certificate /etc/letsencrypt/live/ai.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/ai.example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://127.0.0.1:9000;
+ proxy_http_version 1.1;
+ proxy_buffering off; # SSE streaming
+ proxy_read_timeout 3600s; # long generations
+ }
+}
+```
+
+(`caddy reverse-proxy --from ai.example.com --to 127.0.0.1:9000` does the
+same with automatic certificates.)
+
+## 3. Security checklist
+
+- **Admin token**: set `[admin] token` (or `CORTIQ_ADMIN_TOKEN`) — without it
+ the console warns that it is open to the network.
+- **API keys**: create one per department/developer on the keys page. With at
+ least one key configured the model APIs require Bearer / x-api-key —
+ requests without a valid key get 401.
+- **Rate limits**: per-key requests/min → 429 with Retry-After.
+- **Monthly budgets**: per-key $ ceiling → 429 insufficient_quota when the
+ account's month-to-date spend crosses it.
+- Bind to 127.0.0.1 behind the proxy; never expose the admin console raw.
+
+## 4. Backups
+
+Everything lives under the data directory (`CORTIQ_GATEWAY_HOME`):
+
+- `gateway.toml` — full config incl. keys (or use the console's TOML export);
+- `secrets.toml` — provider API keys (chmod 600);
+- `stats.jsonl` + `stats.jsonl.totals.json` — request accounting incl.
+ cumulative per-key totals and month-to-date budget spend.
+
+Copy those three and you can rebuild the instance anywhere.
+
+## 5. Observability
+
+- Dashboard: KPIs, per-key spend, recent requests with complexity scores,
+ OpenRouter balance (red under $5).
+- `GET /metrics` (enable `[telemetry] metrics = true`) for Prometheus.
+- Logs view in the console; `stats.max_file_mb` keeps the disk bounded.
diff --git a/docs/DEPLOY.ru.md b/docs/DEPLOY.ru.md
new file mode 100644
index 0000000..0008288
--- /dev/null
+++ b/docs/DEPLOY.ru.md
@@ -0,0 +1,123 @@
+# Развёртывание cortiq-gateway для команды / офиса
+
+Один шлюз на небольшом сервере, API-ключи по отделам, локальные модели для
+дешёвых уровней, облачные — для сложных. Эта страница про эксплуатацию.
+
+## 1. Запуск как сервис
+
+### Linux (systemd)
+
+```ini
+# /etc/systemd/system/cortiq-gateway.service
+[Unit]
+Description=Cortiq Gateway
+After=network-online.target
+
+[Service]
+User=cortiq
+Environment=CORTIQ_GATEWAY_HOME=/var/lib/cortiq-gateway
+ExecStart=/usr/local/bin/cortiq-gateway --no-port-search
+Restart=always
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl enable --now cortiq-gateway
+```
+
+### macOS (launchd)
+
+```xml
+
+
+
+
+ Labelcom.cortiq.gateway
+ ProgramArguments
+ /usr/local/bin/cortiq-gateway
+ --no-port-search
+
+ RunAtLoad
+ KeepAlive
+
+```
+
+```bash
+launchctl load ~/Library/LaunchAgents/com.cortiq.gateway.plist
+```
+
+### Windows
+
+Планировщик заданий → Создать задачу → «Выполнять вне зависимости от входа
+пользователя», триггер «При запуске», действие `cortiq-gateway.exe
+--no-port-search`. Либо [NSSM](https://nssm.cc):
+`nssm install cortiq-gateway C:\bin\cortiq-gateway.exe`.
+
+### Docker
+
+```bash
+docker run -d --restart unless-stopped -p 9000:9000 \
+ -v cortiq-data:/data ghcr.io/infosave2007/cortiq-gateway:latest
+```
+
+## 2. TLS и внешний мир
+
+Сам шлюз говорит по HTTP — поставьте перед ним reverse-proxy с TLS, а шлюз
+привяжите к localhost:
+
+```toml
+# gateway.toml
+listen = "127.0.0.1:9000"
+```
+
+```nginx
+server {
+ listen 443 ssl;
+ server_name ai.example.com;
+ ssl_certificate /etc/letsencrypt/live/ai.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/ai.example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://127.0.0.1:9000;
+ proxy_http_version 1.1;
+ proxy_buffering off; # SSE-стриминг
+ proxy_read_timeout 3600s; # долгие генерации
+ }
+}
+```
+
+(`caddy reverse-proxy --from ai.example.com --to 127.0.0.1:9000` делает то же
+с автоматическими сертификатами.)
+
+## 3. Чек-лист безопасности
+
+- **Токен админки**: задайте `[admin] token` (или `CORTIQ_ADMIN_TOKEN`) — без
+ него консоль предупреждает, что открыта сети.
+- **API-ключи**: по ключу на отдел/разработчика на странице ключей. Как только
+ настроен хотя бы один ключ, модельные API требуют Bearer / x-api-key —
+ запросы без валидного ключа получают 401.
+- **Rate-limit**: запросов/мин на ключ → 429 с Retry-After.
+- **Месячные бюджеты**: потолок $ на ключ → 429 insufficient_quota, когда
+ траты аккаунта за месяц превысят лимит.
+- Привязывайте к 127.0.0.1 за прокси; не выставляйте админку наружу голой.
+
+## 4. Бэкапы
+
+Всё живёт в data-директории (`CORTIQ_GATEWAY_HOME`):
+
+- `gateway.toml` — полный конфиг вкл. ключи (или TOML-экспорт из консоли);
+- `secrets.toml` — ключи провайдеров (chmod 600);
+- `stats.jsonl` + `stats.jsonl.totals.json` — учёт запросов вкл. вечные
+ тоталы по ключам и месячные траты для бюджетов.
+
+Скопируйте эти три файла — и инстанс поднимается где угодно.
+
+## 5. Наблюдаемость
+
+- Рабочий стол: KPI, расходы по ключам, последние запросы с коэффициентами
+ сложности, баланс OpenRouter (красный ниже $5).
+- `GET /metrics` (включите `[telemetry] metrics = true`) для Prometheus.
+- Просмотр логов в консоли; `stats.max_file_mb` ограничивает диск.
diff --git a/src/admin/mod.rs b/src/admin/mod.rs
index f4d8b22..96bcb2d 100644
--- a/src/admin/mod.rs
+++ b/src/admin/mod.rs
@@ -155,6 +155,7 @@ pub fn api_routes(admin_token: String) -> Router {
get(get_protocols).put(put_protocols),
)
.route("/admin/api/harnesses", get(get_harnesses))
+ .route("/admin/api/balance", get(get_balance))
.route("/admin/api/settings", get(get_settings).put(put_settings))
.route("/admin/api/keys", get(list_keys).post(create_key))
.route("/admin/api/keys/:key", axum::routing::delete(delete_key))
@@ -1008,6 +1009,51 @@ async fn put_routing(State(state): State, Json(body): Json) -> ApiResult {
+ static CACHE: std::sync::OnceLock>> =
+ std::sync::OnceLock::new();
+ let cache = CACHE.get_or_init(|| std::sync::Mutex::new(None));
+ if let Some((at, v)) = *cache.lock().unwrap() {
+ if at.elapsed() < std::time::Duration::from_secs(300) {
+ return ok(json!({ "openrouter_usd": v }));
+ }
+ }
+ let key = state
+ .secrets
+ .resolve("OPENROUTER_API_KEY")
+ .or_else(|| std::env::var("OPENROUTER_API_KEY").ok());
+ let Some(key) = key.filter(|k| !k.is_empty()) else {
+ return ok(json!({ "openrouter_usd": serde_json::Value::Null }));
+ };
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(8))
+ .build()
+ .unwrap_or_default();
+ let resp = client
+ .get("https://openrouter.ai/api/v1/credits")
+ .bearer_auth(&key)
+ .send()
+ .await;
+ let balance = match resp {
+ Ok(r) if r.status().is_success() => {
+ let v: serde_json::Value = r.json().await.unwrap_or_default();
+ let total = v["data"]["total_credits"].as_f64().unwrap_or(0.0);
+ let used = v["data"]["total_usage"].as_f64().unwrap_or(0.0);
+ Some(total - used)
+ }
+ _ => None,
+ };
+ let Some(balance) = balance else {
+ return ok(json!({ "openrouter_usd": serde_json::Value::Null }));
+ };
+ *cache.lock().unwrap() = Some((std::time::Instant::now(), balance));
+ ok(json!({ "openrouter_usd": balance }))
+}
+
// ──────────────────────────── harness detection ────────────────────────────
// Which coding harnesses are present on the machine the gateway runs on, so the
// API-keys view can mark tabs "installed" / "already configured for cortiq".
@@ -1357,6 +1403,7 @@ async fn list_keys(State(state): State) -> ApiResult {
"key_masked": mask_key(&k.key),
"account": k.account,
"rate_per_min": k.rate_per_min,
+ "monthly_budget_usd": k.monthly_budget_usd,
"allow_models": k.allow_models,
})
})
@@ -1372,6 +1419,8 @@ struct NewKey {
#[serde(default)]
rate_per_min: u32,
#[serde(default)]
+ monthly_budget_usd: Option,
+ #[serde(default)]
allow_models: Vec,
}
@@ -1390,6 +1439,7 @@ async fn create_key(State(state): State, Json(b): Json) ->
account: b.account,
rate_per_min: b.rate_per_min,
allow_models: b.allow_models,
+ monthly_budget_usd: b.monthly_budget_usd,
});
state.reload(cfg)?;
// show the full key only once
diff --git a/src/config.rs b/src/config.rs
index 4151578..9aff363 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -620,6 +620,9 @@ pub struct ApiKeyCfg {
pub account: String,
#[serde(default)]
pub rate_per_min: u32,
+ /// Monthly spend ceiling in USD for this key's account; 0/absent = none.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub monthly_budget_usd: Option,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow_models: Vec,
}
diff --git a/src/error.rs b/src/error.rs
index 5a11244..c954497 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,6 +13,8 @@ pub enum GatewayError {
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}")]
@@ -37,6 +39,11 @@ impl GatewayError {
"rate_limit_error",
"rate_limited",
),
+ Self::BudgetExhausted { .. } => (
+ StatusCode::TOO_MANY_REQUESTS,
+ "insufficient_quota",
+ "insufficient_quota",
+ ),
Self::UpstreamUnavailable(_) => (
StatusCode::BAD_GATEWAY,
"upstream_unavailable",
diff --git a/src/main.rs b/src/main.rs
index 9e52e0e..142b4aa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -258,7 +258,7 @@ async fn main() -> anyhow::Result<()> {
.route("/health", get(|| async { "ok" }))
.route("/readyz", get(|| async { "ready" }))
.route("/metrics", get(admin::metrics))
- .merge(protocols::build_router());
+ .merge(protocols::build_router(state.clone()));
if admin_enabled {
app = app
@@ -271,7 +271,14 @@ async fn main() -> anyhow::Result<()> {
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
- let app = app.with_state(state).layer(cors);
+ // 32 MB request ceiling: enough for base64 keyframes and TOML imports,
+ // small enough that a hostile client can't balloon memory
+ let app =
+ app.with_state(state)
+ .layer(cors)
+ .layer(tower_http::limit::RequestBodyLimitLayer::new(
+ 32 * 1024 * 1024,
+ ));
let listener = {
let mut attempt = 0u32;
@@ -340,6 +347,11 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("open this URL to finish setup: {url}");
}
}
- axum::serve(listener, app).await?;
+ axum::serve(listener, app)
+ .with_graceful_shutdown(async {
+ let _ = tokio::signal::ctrl_c().await;
+ tracing::info!("shutdown signal received — draining connections");
+ })
+ .await?;
Ok(())
}
diff --git a/src/protocols/anthropic_messages.rs b/src/protocols/anthropic_messages.rs
index 3f6e3d4..332cf60 100644
--- a/src/protocols/anthropic_messages.rs
+++ b/src/protocols/anthropic_messages.rs
@@ -63,6 +63,7 @@ fn parse_routing(model: &str) -> RoutingDirective {
async fn handler(
State(state): State,
+ acct: Option>,
Json(req): Json,
) -> Result {
if !state.live().cfg.protocols.anthropic_messages {
@@ -108,6 +109,7 @@ async fn handler(
stream: req.stream,
meta: RequestMeta {
protocol: "anthropic_messages".into(),
+ account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
..Default::default()
},
};
diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs
index 4d7193c..55c7ea1 100644
--- a/src/protocols/mod.rs
+++ b/src/protocols/mod.rs
@@ -12,10 +12,59 @@ 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,
+ 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() -> Router {
+pub fn build_router(state: SharedState) -> Router {
Router::new()
.merge(openai_chat::routes())
.merge(openai_completions::routes())
@@ -24,4 +73,5 @@ pub fn build_router() -> Router {
.merge(anthropic_messages::routes())
.merge(mcp::routes())
.merge(native_passthrough::routes())
+ .route_layer(axum::middleware::from_fn_with_state(state, api_auth))
}
diff --git a/src/protocols/openai_chat.rs b/src/protocols/openai_chat.rs
index d17592e..7f1c1a8 100644
--- a/src/protocols/openai_chat.rs
+++ b/src/protocols/openai_chat.rs
@@ -75,6 +75,7 @@ fn parse_routing(model: &str) -> RoutingDirective {
async fn handler(
State(state): State,
+ acct: Option>,
Json(req): Json,
) -> Result {
// hot protocol toggle: if the adapter is disabled in config — return 404
@@ -104,6 +105,7 @@ async fn handler(
stream: req.stream,
meta: RequestMeta {
protocol: "openai_chat".into(),
+ account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
..Default::default()
},
};
diff --git a/src/protocols/openai_completions.rs b/src/protocols/openai_completions.rs
index df47d7d..a180609 100644
--- a/src/protocols/openai_completions.rs
+++ b/src/protocols/openai_completions.rs
@@ -56,6 +56,7 @@ fn prompt_to_string(v: &serde_json::Value) -> String {
async fn handler(
State(state): State,
+ acct: Option>,
Json(req): Json,
) -> Result {
if !state.live().cfg.protocols.openai_completions {
@@ -89,6 +90,7 @@ async fn handler(
stream: req.stream,
meta: RequestMeta {
protocol: "openai_completions".into(),
+ account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
..Default::default()
},
};
diff --git a/src/state.rs b/src/state.rs
index 8ebf558..5718ac0 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -66,6 +66,42 @@ impl Live {
}
}
+/// Fixed-window per-key rate limiter (minute buckets). Small and honest:
+/// at minute boundaries a burst can briefly see up to 2× the limit, which is
+/// fine for office-grade fairness and costs one HashMap lookup per request.
+#[derive(Default)]
+pub struct RateWindows {
+ inner: std::sync::Mutex>,
+}
+
+impl RateWindows {
+ pub fn allow(&self, key: &str, per_min: u32) -> bool {
+ let minute = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+ / 60;
+ let mut g = self.inner.lock().unwrap();
+ let e = g.entry(key.to_string()).or_insert((minute, 0));
+ if e.0 != minute {
+ *e = (minute, 0);
+ }
+ if e.1 >= per_min {
+ return false;
+ }
+ e.1 += 1;
+ true
+ }
+ /// Seconds until the current minute window rolls over.
+ pub fn retry_after_secs(&self) -> u64 {
+ 60 - (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+ % 60)
+ }
+}
+
pub struct AppState {
pub live: ArcSwap,
pub stats: Arc,
@@ -85,6 +121,8 @@ pub struct AppState {
/// Managed local CMF model server (install/update/spawn lifecycle + status).
pub cmf: Arc,
pub cache: Arc,
+ /// Per-API-key fixed-window rate limiter.
+ pub rate: RateWindows,
pub secrets: SecretStore,
pub config_path: String,
pub pipeline: crate::pipeline::Pipeline,
@@ -164,6 +202,7 @@ impl SharedState {
admin_open_public: std::sync::atomic::AtomicBool::new(false),
cmf,
cache,
+ rate: RateWindows::default(),
secrets,
config_path,
pipeline: crate::pipeline::Pipeline::new(),
diff --git a/src/stats.rs b/src/stats.rs
index fc14106..f6382af 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -137,6 +137,11 @@ struct Inner {
/// = folded + by_account_alltime, with no double counting: a record is
/// either in the file or folded, never both.
folded: HashMap,
+ /// Month-to-date spend per account ("YYYY-MM", usd) — budgets check this.
+ month_spend: HashMap,
+ /// Records with ts ≤ this are already reflected in the persisted
+ /// month_spend (watermark against double counting on replay).
+ month_upto_ts: u64,
/// Appends since the last compaction size check.
writes_since_check: u32,
}
@@ -193,6 +198,7 @@ impl Stats {
.entry(rec.account.clone())
.or_default()
.apply(&rec);
+ Self::apply_month(&mut inner, &rec);
if rec.ts >= cutoff {
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
}
@@ -200,6 +206,33 @@ impl Stats {
}
}
+ fn apply_month(inner: &mut Inner, rec: &RequestRecord) {
+ if rec.ts <= inner.month_upto_ts || rec.cost_usd <= 0.0 {
+ return;
+ }
+ let m = month_of(rec.ts);
+ let e = inner
+ .month_spend
+ .entry(rec.account.clone())
+ .or_insert((m.clone(), 0.0));
+ if e.0 != m {
+ *e = (m, 0.0);
+ }
+ e.1 += rec.cost_usd;
+ }
+
+ /// Month-to-date spend for an account (budget checks).
+ pub fn month_spend(&self, account: &str) -> f64 {
+ let inner = self.inner.lock().unwrap();
+ let m = month_of(now_secs());
+ inner
+ .month_spend
+ .get(account)
+ .filter(|(mm, _)| *mm == m)
+ .map(|(_, v)| *v)
+ .unwrap_or(0.0)
+ }
+
fn push_recent(inner: &mut Inner, rec: &RequestRecord, ring_size: usize) {
inner.recent.push_back(rec.clone());
while inner.recent.len() > ring_size {
@@ -256,6 +289,7 @@ impl Stats {
.entry(rec.account.clone())
.or_default()
.apply(&rec);
+ Self::apply_month(&mut inner, &rec);
inner.writes_since_check += 1;
}
if let Some(path) = &self.file {
@@ -283,11 +317,42 @@ impl Stats {
let Ok(content) = std::fs::read_to_string(&path) else {
return;
};
- if let Ok(m) = serde_json::from_str::>(&content) {
+ #[derive(serde::Deserialize)]
+ struct Sidecar {
+ #[serde(default)]
+ by_account: HashMap,
+ #[serde(default)]
+ month_spend: HashMap,
+ #[serde(default)]
+ month_upto_ts: u64,
+ }
+ // new format {by_account, month_spend, month_upto_ts}; the original
+ // file held the bare by_account map — accept both
+ if let Ok(sc) = serde_json::from_str::(&content) {
+ let mut inner = self.inner.lock().unwrap();
+ inner.folded = sc.by_account;
+ inner.month_spend = sc.month_spend;
+ inner.month_upto_ts = sc.month_upto_ts;
+ } else if let Ok(m) = serde_json::from_str::>(&content) {
self.inner.lock().unwrap().folded = m;
}
}
+ fn write_sidecar(&self, inner: &Inner) {
+ let Some(tpath) = self.totals_path() else {
+ return;
+ };
+ let payload = serde_json::json!({
+ "by_account": inner.folded,
+ "month_spend": inner.month_spend,
+ "month_upto_ts": now_secs(),
+ });
+ let tmp = format!("{tpath}.tmp");
+ if std::fs::write(&tmp, payload.to_string()).is_ok() {
+ let _ = std::fs::rename(&tmp, &tpath);
+ }
+ }
+
/// Keep the JSONL bounded: once it outgrows `max_file_bytes`, records
/// older than the retention window fold into the cumulative per-account
/// sidecar and are dropped from the file (tmp + rename on both files, so
@@ -305,6 +370,10 @@ impl Stats {
return;
}
inner.writes_since_check = 0;
+ // piggyback: persist month-to-date spend so budgets survive
+ // restarts even between compactions
+ inner.month_upto_ts = now_secs();
+ self.write_sidecar(&inner);
}
}
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
@@ -359,14 +428,8 @@ impl Stats {
}
}
// persist the folded totals FIRST, then swap the trimmed file in
- if let Some(tpath) = self.totals_path() {
- if let Ok(json) = serde_json::to_string(&inner.folded) {
- let tmp = format!("{tpath}.tmp");
- if std::fs::write(&tmp, json).is_ok() {
- let _ = std::fs::rename(&tmp, &tpath);
- }
- }
- }
+ inner.month_upto_ts = now_secs();
+ self.write_sidecar(&inner);
let tmp = format!("{path}.tmp");
if std::fs::write(&tmp, kept).is_ok() {
let _ = std::fs::rename(&tmp, path);
@@ -569,6 +632,46 @@ fn fill_bucket(b: &mut Bucket, r: &RequestRecord) {
b.by_label.entry(r.task_label.clone()).or_default().apply(r);
}
+fn month_of(ts: u64) -> String {
+ // календарный месяц UTC без внешних зависимостей: дни с эпохи → (год, месяц)
+ let days = ts / 86_400;
+ let mut year = 1970u64;
+ let mut d = days;
+ loop {
+ let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
+ let len = if leap { 366 } else { 365 };
+ if d < len {
+ break;
+ }
+ d -= len;
+ year += 1;
+ }
+ let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
+ let ml = [
+ 31,
+ if leap { 29 } else { 28 },
+ 31,
+ 30,
+ 31,
+ 30,
+ 31,
+ 31,
+ 30,
+ 31,
+ 30,
+ 31,
+ ];
+ let mut month = 1;
+ for len in ml {
+ if d < len {
+ break;
+ }
+ d -= len;
+ month += 1;
+ }
+ format!("{year:04}-{month:02}")
+}
+
fn avg(sum: u64, n: u64) -> u64 {
sum.checked_div(n).unwrap_or(0)
}
diff --git a/web/js/api.js b/web/js/api.js
index 276bfd2..32230ac 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -74,6 +74,7 @@ export const api = {
// protocols
getProtocols: () => j("GET", "/protocols"),
harnesses: () => j("GET", "/harnesses"),
+ balance: () => j("GET", "/balance"),
putProtocols: (b) => j("PUT", "/protocols", b),
// settings
diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js
index 3cf9684..16b28b7 100644
--- a/web/js/i18n/de.js
+++ b/web/js/i18n/de.js
@@ -639,4 +639,8 @@ export default {
"settings.cache.entries": "Einträge",
"settings.cache.hits": "Treffer",
"settings.cache.saved": "gespart",
+ "keys.form.budget": "Monatsbudget, $ (0 = unbegrenzt)",
+ "keys.form.budgetHint": "Überschreiten die Monatsausgaben des Kontos die Grenze, werden Anfragen mit 429 insufficient_quota abgelehnt.",
+ "keys.budgetOf": "Budget {b}/Mon.",
+ "dash.kpi.orBalance": "OpenRouter-Guthaben",
};
diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js
index f7c292d..fad52b8 100644
--- a/web/js/i18n/en.js
+++ b/web/js/i18n/en.js
@@ -632,4 +632,8 @@ export default {
"settings.cache.entries": "entries",
"settings.cache.hits": "hits",
"settings.cache.saved": "saved",
+ "keys.form.budget": "Monthly budget, $ (0 = unlimited)",
+ "keys.form.budgetHint": "Requests are rejected with 429 insufficient_quota once the account's month-to-date spend crosses this.",
+ "keys.budgetOf": "budget {b}/mo",
+ "dash.kpi.orBalance": "OpenRouter balance",
};
diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js
index 9fc8ee9..d3ca8e6 100644
--- a/web/js/i18n/es.js
+++ b/web/js/i18n/es.js
@@ -639,4 +639,8 @@ export default {
"settings.cache.entries": "entradas",
"settings.cache.hits": "aciertos",
"settings.cache.saved": "ahorrado",
+ "keys.form.budget": "Presupuesto mensual, $ (0 = sin límite)",
+ "keys.form.budgetHint": "Cuando el gasto del mes supera el techo, las peticiones se rechazan con 429 insufficient_quota.",
+ "keys.budgetOf": "presupuesto {b}/mes",
+ "dash.kpi.orBalance": "Saldo OpenRouter",
};
diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js
index ba87394..a91fd5d 100644
--- a/web/js/i18n/fr.js
+++ b/web/js/i18n/fr.js
@@ -639,4 +639,8 @@ export default {
"settings.cache.entries": "entrées",
"settings.cache.hits": "hits",
"settings.cache.saved": "économisé",
+ "keys.form.budget": "Budget mensuel, $ (0 = illimité)",
+ "keys.form.budgetHint": "Quand les dépenses du mois dépassent le plafond, les requêtes sont rejetées avec 429 insufficient_quota.",
+ "keys.budgetOf": "budget {b}/mois",
+ "dash.kpi.orBalance": "Solde OpenRouter",
};
diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js
index b0cd955..5bd19ad 100644
--- a/web/js/i18n/ru.js
+++ b/web/js/i18n/ru.js
@@ -631,4 +631,8 @@ export default {
"settings.cache.entries": "записей",
"settings.cache.hits": "попаданий",
"settings.cache.saved": "сэкономлено",
+ "keys.form.budget": "Бюджет в месяц, $ (0 = без лимита)",
+ "keys.form.budgetHint": "Когда траты аккаунта за месяц превысят потолок, запросы отклоняются с 429 insufficient_quota.",
+ "keys.budgetOf": "бюджет {b}/мес",
+ "dash.kpi.orBalance": "Баланс OpenRouter",
};
diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js
index c92f902..b9e81ee 100644
--- a/web/js/i18n/tr.js
+++ b/web/js/i18n/tr.js
@@ -639,4 +639,8 @@ export default {
"settings.cache.entries": "kayıt",
"settings.cache.hits": "isabet",
"settings.cache.saved": "tasarruf",
+ "keys.form.budget": "Aylık bütçe, $ (0 = sınırsız)",
+ "keys.form.budgetHint": "Hesabın aylık harcaması tavanı aşınca istekler 429 insufficient_quota ile reddedilir.",
+ "keys.budgetOf": "bütçe {b}/ay",
+ "dash.kpi.orBalance": "OpenRouter bakiyesi",
};
diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js
index 82b8e75..cd03cff 100644
--- a/web/js/i18n/zh.js
+++ b/web/js/i18n/zh.js
@@ -639,4 +639,8 @@ export default {
"settings.cache.entries": "条目",
"settings.cache.hits": "命中",
"settings.cache.saved": "已节省",
+ "keys.form.budget": "每月预算,$(0 = 不限)",
+ "keys.form.budgetHint": "当账户当月支出超过上限时,请求将被拒绝并返回 429 insufficient_quota。",
+ "keys.budgetOf": "预算 {b}/月",
+ "dash.kpi.orBalance": "OpenRouter 余额",
};
diff --git a/web/js/views/dashboard.js b/web/js/views/dashboard.js
index c23a27d..bf59280 100644
--- a/web/js/views/dashboard.js
+++ b/web/js/views/dashboard.js
@@ -110,12 +110,13 @@ export async function renderDashboard() {
async function load() {
const rangeSecs = range;
- let stats, recent, health;
+ let stats, recent, health, balance;
try {
- [stats, recent, health] = await Promise.all([
+ [stats, recent, health, balance] = await Promise.all([
api.stats(rangeSecs, groupby),
api.requests(40, 0),
api.health().catch(() => null),
+ api.balance().catch(() => null),
]);
} catch (e) {
mount(root, h("div", { class: "callout" }, String(e.message || e)));
@@ -162,6 +163,11 @@ export async function renderDashboard() {
kpi(num(tot.requests), t("dash.kpi.requests")),
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
kpi(moneyEl(tot.cost_usd), t("dash.kpi.cost")),
+ balance && balance.openrouter_usd != null
+ ? kpi(
+ h("span", { class: balance.openrouter_usd < 5 ? "err" : "" }, "$" + balance.openrouter_usd.toFixed(2)),
+ t("dash.kpi.orBalance"))
+ : null,
kpi(ms(tot.avg_latency_ms), t("dash.kpi.latency")),
kpi(pct(tot.success_rate), t("dash.kpi.success")),
kpi(num(tot.failovers), t("dash.kpi.failovers"))
diff --git a/web/js/views/keys.js b/web/js/views/keys.js
index 0c63f31..c3d4b27 100644
--- a/web/js/views/keys.js
+++ b/web/js/views/keys.js
@@ -10,12 +10,14 @@ export async function renderKeys() {
function openCreate(reload) {
const accountIn = h("input", { placeholder: "team-a" });
const rateIn = h("input", { type: "number", value: 600 });
+ const budgetIn = h("input", { type: "number", step: "1", min: "0", placeholder: "0" });
const customIn = h("input", { placeholder: "sk-gw-…" });
const body = h(
"div",
{},
h("label", { class: "field" }, h("span", {}, t("keys.form.account")), accountIn),
h("label", { class: "field" }, h("span", {}, t("keys.form.rate")), rateIn),
+ h("label", { class: "field" }, h("span", {}, t("keys.form.budget")), budgetIn, h("div", { class: "hint" }, t("keys.form.budgetHint"))),
h("label", { class: "field" }, h("span", {}, t("keys.form.custom")), customIn, h("div", { class: "hint" }, t("keys.form.customHint")))
);
modal(t("keys.form.title"), body, async () => {
@@ -24,6 +26,7 @@ export async function renderKeys() {
key: customIn.value.trim(),
account: accountIn.value.trim() || "default",
rate_per_min: parseInt(rateIn.value) || 0,
+ monthly_budget_usd: parseFloat(budgetIn.value) > 0 ? parseFloat(budgetIn.value) : null,
allow_models: [],
});
reload();
@@ -273,7 +276,12 @@ console.log(resp.choices[0].message.content);`,
h("td", { class: "right-align mono" }, String((usage[k.account] || {}).requests ?? "—")),
h("td", { class: "right-align mono small" }, usage[k.account] ? num(usage[k.account].prompt_tokens) + " / " + num(usage[k.account].completion_tokens) : "—"),
h("td", { class: "right-align" }, usage[k.account] ? moneyEl(usage[k.account].cost_usd) : "—"),
- h("td", { class: "right-align" }, alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—"),
+ h("td", { class: "right-align" },
+ alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—",
+ k.monthly_budget_usd
+ ? h("div", { class: "small " + (((usage[k.account] || {}).cost_usd || 0) >= k.monthly_budget_usd ? "err" : "muted") },
+ t("keys.budgetOf", { b: "$" + k.monthly_budget_usd }))
+ : null),
h(
"td",
{ class: "right-align" },