This commit is contained in:
retoor 2026-07-21 09:48:37 +02:00
parent 34f76aad65
commit 77f043640e
23 changed files with 993 additions and 11 deletions

View File

@ -53,6 +53,9 @@ devplace attachments prune # remove orphan attachment records/files
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # reset every guest quota
devplace devii reset-quota --all # reset every quota (users and guests)
devplace gateway quota list # list AI gateway quota rules and current 24h spend
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
devplace gateway quota delete <uid> # delete a quota rule
devplace zips prune # delete expired zip archives + job rows
devplace zips clear # delete every zip archive + job row
devplace forks prune # delete expired completed fork job rows (forked projects persist)
@ -326,3 +329,4 @@ Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.

View File

@ -33,7 +33,7 @@ EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]

103
devplacepy/cli/gateway.py Normal file
View File

@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_gateway_quota_list(args):
from devplacepy.services.openai_gateway import quota
rules = quota.quota_rule_store.list()
if not rules:
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
return
for rule in rules:
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
scope = ", ".join(
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
) or "(no dimensions - invalid)"
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
active = "active" if rule["is_active"] else "inactive"
label = f" - {rule['label']}" if rule["label"] else ""
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
def cmd_gateway_quota_set(args):
from pydantic import ValidationError
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaRuleIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
limit_usd=args.limit_usd,
is_active=not args.inactive,
label=args.label or "",
)
except ValidationError as exc:
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
sys.exit(1)
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
_audit_cli(
"gateway.quota_rule.update",
f"CLI saved gateway quota rule {saved['uid']}",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
},
target_type="gateway_quota_rule",
target_uid=saved["uid"],
)
print(f"Saved quota rule {saved['uid']}")
def cmd_gateway_quota_delete(args):
from devplacepy.services.openai_gateway import quota
if not quota.quota_rule_store.remove(args.uid):
print(f"Quota rule '{args.uid}' not found")
sys.exit(1)
_audit_cli(
"gateway.quota_rule.delete",
f"CLI deleted gateway quota rule {args.uid}",
target_type="gateway_quota_rule",
target_uid=args.uid,
)
print(f"Deleted quota rule {args.uid}")
def register_gateway(subparsers):
gateway = subparsers.add_parser("gateway", help="AI gateway management")
gateway_sub = gateway.add_subparsers(title="action", dest="action")
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
quota_list.set_defaults(func=cmd_gateway_quota_list)
quota_set = quota_sub.add_parser(
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
)
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
quota_set.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit for any role",
)
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
quota_set.add_argument(
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
)
quota_set.add_argument("--label", help="Optional admin-facing note")
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
quota_set.set_defaults(func=cmd_gateway_quota_set)
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
quota_delete.add_argument("uid", help="Quota rule uid")
quota_delete.set_defaults(func=cmd_gateway_quota_delete)

View File

@ -13,6 +13,7 @@ from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.game import register_game
from devplacepy.cli.gateway import register_gateway
def build_parser():
@ -30,6 +31,7 @@ def build_parser():
register_containers(sub)
register_migrate(sub)
register_game(sub)
register_gateway(sub)
return parser

View File

@ -18,6 +18,7 @@ NOTIFICATION_TYPES = [
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
]

View File

@ -518,6 +518,10 @@ def init_db():
from devplacepy.services.openai_gateway import routing as gateway_routing
gateway_routing.ensure_tables()
from devplacepy.services.openai_gateway import quota as gateway_quota
gateway_quota.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"])

View File

@ -613,6 +613,53 @@ four ways to sign requests.
auth="admin",
destructive=True,
),
endpoint(
id="admin-gateway-quota-rules",
method="GET",
path="/admin/gateway/quota-rules",
title="List AI gateway quota rules",
summary=(
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
"combination of role, specific user uid, and app_reference label, plus the "
"global per-role default caps that apply when no rule matches."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-quota-rule-set",
method="POST",
path="/admin/gateway/quota-rules",
title="Create or update an AI gateway quota rule",
summary=(
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
"wildcard, and the most specific active match wins over other rules and over "
"the global default. Pass uid to update an existing rule."
),
auth="admin",
params=[
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
field("label", "json", "string", False, "", "Optional admin-facing note."),
],
),
endpoint(
id="admin-gateway-quota-rule-delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
title="Delete an AI gateway quota rule",
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
],
),
endpoint(
id="admin-bots-monitor",
method="GET",

View File

@ -9,7 +9,7 @@ from pydantic import ValidationError
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import routing
from devplacepy.services.openai_gateway import quota, routing
from devplacepy.templating import templates
from devplacepy.utils import require_admin
@ -182,3 +182,92 @@ async def delete_model(request: Request, source_model: str):
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
)
return JSONResponse({"ok": True})
def _quota_defaults_summary() -> dict:
svc = service_manager.get_service("openai")
cfg = svc.get_config() if svc is not None else {}
return {
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
}
def _rule_label(rule: dict) -> str:
parts = []
if rule.get("owner_kind"):
parts.append(f"role={rule['owner_kind']}")
if rule.get("owner_id"):
parts.append(f"user={rule['owner_id']}")
if rule.get("app_reference"):
parts.append(f"app={rule['app_reference']}")
return ", ".join(parts) or rule.get("uid", "")
@router.get("/gateway/quota-rules")
async def list_quota_rules(request: Request):
require_admin(request)
rules = quota.quota_rule_store.list()
for rule in rules:
rule["spent_24h_usd"] = round(
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
)
return JSONResponse(
{
"rules": rules,
"count": len(rules),
"defaults": _quota_defaults_summary(),
}
)
@router.post("/gateway/quota-rules")
async def save_quota_rule(request: Request):
admin = require_admin(request)
body = await _payload(request)
uid = str(body.pop("uid", "") or "").strip() or None
try:
payload = quota.QuotaRuleIn(**body)
except ValidationError as exc:
return _validation_error(exc)
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
audit.record(
request,
"gateway.quota_rule.update",
user=admin,
target_type="gateway_quota_rule",
target_uid=saved["uid"],
target_label=_rule_label(saved),
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
"is_active": saved["is_active"],
},
)
return JSONResponse({"ok": True, "rule": saved})
@router.delete("/gateway/quota-rules/{uid}")
async def delete_quota_rule(request: Request, uid: str):
admin = require_admin(request)
existing = quota.quota_rule_store.get(uid)
label = _rule_label(existing.as_dict()) if existing else uid
existed = quota.quota_rule_store.remove(uid)
if not existed:
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
audit.record(
request,
"gateway.quota_rule.delete",
user=admin,
target_type="gateway_quota_rule",
target_uid=uid,
target_label=label,
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
)
return JSONResponse({"ok": True})

View File

@ -4,6 +4,7 @@ import logging
from devplacepy.database import get_correction_usage, get_modifier_usage
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import quota as gateway_quota
from devplacepy.services.openai_gateway.analytics import user_spend_24h
logger = logging.getLogger(__name__)
@ -62,4 +63,22 @@ def _ai_quota(
if include_cost:
quota["spent_usd"] = round(spent, 4)
quota["limit_usd"] = round(limit, 2)
gateway_svc = service_manager.get_service("openai")
if gateway_svc is not None:
try:
owner_kind = "admin" if is_admin else "user"
cfg = gateway_svc.effective_config()
gw_limit, gw_scope, gw_rule = gateway_quota.resolve_for_owner(owner_kind, user_uid, cfg)
gw_spent = gateway_quota.spent_24h(*gw_scope)
gw_unlimited = gw_limit <= 0
quota["gateway_unlimited"] = gw_unlimited
quota["gateway_used_pct"] = (
0.0 if gw_unlimited else round(min(100.0, gw_spent / gw_limit * 100), 1)
)
if include_cost:
quota["gateway_spent_usd"] = round(gw_spent, 4)
quota["gateway_limit_usd"] = round(gw_limit, 2)
quota["gateway_pooled"] = bool(gw_rule and gw_rule.owner_id is None)
except Exception:
logger.exception("Failed to compute gateway-level AI quota for %s", user_uid)
return quota

View File

@ -117,4 +117,57 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
requires_admin=True,
params=(path("source_model", "Source model name."), confirm()),
),
Action(
name="gateway_quota_rules",
method="GET",
path="/admin/gateway/quota-rules",
summary="List AI gateway quota rules and the current global defaults (admin only)",
description=(
"Returns JSON: every quota rule (each scoped by any combination of role/owner_kind, "
"a specific user uid, and an app_reference label) with its 24h limit, current 24h spend "
"against that exact scope, active flag, and label, plus the global per-role default caps "
"used when no rule matches a request."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_quota_rule_set",
method="POST",
path="/admin/gateway/quota-rules",
summary="Create or update an AI gateway quota rule (admin only)",
description=(
"Caps rolling-24h USD spend on /openai/v1/*. Scope by any combination of owner_kind "
"(internal/key/user/admin/anonymous), a specific owner_id (user uid), and app_reference "
"(the X-App-Reference header apps send). At least one of the three must be set - an "
"unscoped cap belongs in the global default fields on the gateway service config instead. "
"Leaving a dimension blank makes it a wildcard: an app_reference-only rule pools spend "
"across every caller using that app; an owner_kind-only rule pools spend across every "
"caller of that role. Setting owner_id pins the rule to one specific caller. When several "
"rules match one request, the MOST SPECIFIC one wins (most non-blank dimensions); ties "
"break toward the smaller limit. limit_usd of 0 means unlimited for that rule. Pass uid "
"to update an existing rule instead of creating a new one."
),
handler="http",
requires_admin=True,
params=(
body("uid", "Existing rule uid to update; omit to create a new rule."),
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = any role."),
body("owner_id", "Specific user uid to scope by. Blank = any caller of the matched role."),
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = any app."),
Param(name="limit_usd", location="body", description="Rolling 24h USD cap for this rule. 0 = unlimited.", required=True, type="number"),
Param(name="is_active", location="body", description="Whether the rule is enforced ('1' or '0'). Defaults to active.", required=False, type="boolean"),
body("label", "Optional admin-facing note describing what this rule is for."),
),
),
Action(
name="gateway_quota_rule_delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
summary="Delete an AI gateway quota rule (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("uid", "Quota rule uid."), confirm()),
),
)

View File

@ -58,6 +58,7 @@ CONFIRM_REQUIRED = {
"notification_reset",
"gateway_provider_delete",
"gateway_model_delete",
"gateway_quota_rule_delete",
"email_account_delete",
"email_delete_message",
}

View File

@ -520,8 +520,8 @@ class DeviiSession:
f"{self._system_prompt}\n\n"
f"{CA_IWP_SYSTEM_FRAGMENT}\n\n"
f"{channel_block}\n\n"
f"{self._clock_line()}\n\n"
f"{section}"
f"{section}\n\n"
f"{self._clock_line()}"
)
def _clock_line(self) -> str:
@ -538,7 +538,7 @@ class DeviiSession:
offset = local_now.utcoffset()
offset_text = _format_offset(offset)
local = (
f" The user's local time is {local_now.strftime('%Y-%m-%d %H:%M:%S')} "
f" The user's local time is {local_now.strftime('%Y-%m-%d %Hh')} "
f"({tz_name}, UTC{offset_text})."
)
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
@ -547,16 +547,21 @@ class DeviiSession:
offset = timedelta(minutes=self._tz_offset_minutes)
local = (
f" The user's local time is "
f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} "
f"{(now + offset).strftime('%Y-%m-%d %Hh')} "
f"(UTC{_format_offset(offset)})."
)
return (
f"# CURRENT TIME\n"
f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} "
f"The current UTC time is approximately {now.strftime('%Y-%m-%d %Hh')} UTC "
f"(rounded to the hour for situational awareness only).{local} "
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
"interpret it in the user's local timezone and convert it to UTC for the run_at field. "
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
"instead and do not compute an absolute time."
"instead and do not compute an absolute time - it is applied against the exact time on "
"the server when the task is created, regardless of the rounding above. If you ever need "
"the exact current time to the second - for example to compute an absolute run_at from a "
"phrase you cannot express as delay_seconds - call the current_time tool first; never "
"derive an absolute run_at from the rounded line above."
)
def _stored_timezone(self) -> str:

View File

@ -56,6 +56,23 @@ SCHEDULE_FIELDS: tuple[Param, ...] = (
)
TASK_ACTIONS: tuple[Action, ...] = (
Action(
name="current_time",
method="LOCAL",
path="",
summary="Get the exact current UTC time, to the second",
description=(
"The CURRENT TIME line in the system prompt is rounded to the hour to stay "
"cache-friendly. Call this tool first whenever you need the precise current time - "
"in particular before computing any absolute run_at for create_task/update_task from "
"a relative phrase you cannot express via delay_seconds. For an ordinary relative "
"delay ('in 40 seconds', 'in 2 hours'), you do not need this: delay_seconds is applied "
"against the server's own clock at creation time and is always exact regardless."
),
handler="task",
requires_auth=False,
params=(),
),
Action(
name="create_task",
method="LOCAL",

View File

@ -69,6 +69,7 @@ class TaskController:
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handlers = {
"current_time": self.current_time,
"create_task": self.create_task,
"list_tasks": self.list_tasks,
"get_task": self.get_task,
@ -81,6 +82,9 @@ class TaskController:
raise ToolInputError(f"Unknown task tool: {name}")
return handler(arguments)
def current_time(self, arguments: dict[str, Any]) -> str:
return json.dumps({"utc": to_iso(now_utc())}, ensure_ascii=False)
def create_task(self, arguments: dict[str, Any]) -> str:
prompt = str(arguments.get("prompt", "")).strip()
if not prompt:

View File

@ -61,6 +61,20 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
**Financial data is admin-only everywhere.** Any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the percentage of quota used - this rule is enforced consistently across the profile card, `ai_correction`/`ai_modifier` usage displays, and the Devii cost tools.
## Quota rules (`quota.py`, admin `/admin/gateway` "Quota rules" section)
**This caps `/openai/v1/*` itself, independent of Devii's own daily cap.** Devii's `devii_user_daily_usd`/`devii_guest_daily_usd`/`devii_admin_daily_usd` (documented in `devplacepy/services/devii/CLAUDE.md`) only gate turns that go *through* Devii. A caller hitting the gateway directly with their own `api_key` bypasses that entirely - `quota.py` is the root-level enforcement that closes this, checked in `GatewayService.handle()` right after owner/`app_reference` resolution and before every billed dispatch (chat, embeddings, images, passthrough; `GET /v1/models` is exempt, it makes no upstream call).
**Two layers, same shape as provider/model routing above.** Layer A is five flat `config_fields` on `GatewayService` (`gateway_default_user_daily_usd` $1.00, `gateway_default_admin_daily_usd` $0/unlimited, `gateway_default_guest_daily_usd` $0.05, `gateway_default_internal_daily_usd` $0/unlimited, `gateway_default_key_daily_usd` $0/unlimited - group **Quota**) applied per specific caller (`owner_id`) when no rule matches; internal/key default unlimited so shipping this never starts blocking DevPlace's own news/bots/Devii-guest/correction traffic on the internal key. Layer B is the `gateway_quota_rules` table (`ensure_tables()`, called from `init_db()` alongside `routing.ensure_tables()`; hard CRUD, not in `SOFT_DELETE_TABLES`, cross-worker cache-invalidated under the `"gateway_quota"` name): each row scopes by **any combination** of `owner_kind` (internal/key/user/admin/anonymous - DevPlace's only "roles" here), a specific `owner_id`, and `app_reference` (the `X-App-Reference` label), each nullable = wildcard; a `QuotaRuleIn` Pydantic validator rejects a rule with all three blank (that belongs in Layer A). `quota.resolve(owner_kind, owner_id, app_reference, cfg)` gathers every active rule whose non-null dimensions all equal the request, picks the one with the most non-null dimensions (ties broken toward the smaller limit, unlimited `0` never wins a tie against a finite cap), and returns `(limit_usd, scope, rule)` where `scope` is the exact `(owner_kind, owner_id, app_reference)` triple - each possibly `None` - that spend must be summed over. Layer A is internally just the maximally-specific implicit scope `(owner_kind, owner_id, None)`, so one code path (`quota.spent_24h(*scope)`, a plain `SUM(cost_usd)` over `gateway_usage_ledger` filtered by whichever scope dimensions are non-null) serves both layers.
**A wildcard dimension means a shared pool, by design.** A rule scoped only by `app_reference` caps that app's combined spend across every caller using it; a rule scoped only by `owner_kind` caps that whole role's combined spend. Pin `owner_id` to get a true per-caller cap (the Layer A default's own behavior). `anonymous`/`internal`/`key` owner_ids are already fixed constants (`"anonymous"`/`"devii"`/`"access"`, from `resolve_owner()`), not per-caller identities, so any cap on those kinds is inherently pooled - there is no per-guest identity at this layer (unlike Devii's own guest-cookie-scoped ledger).
**No lock, no hold, bounded overshoot by design - this is deliberate, not an oversight.** Cost is only known after the upstream call returns, so a true atomic pre-authorization would need a reserve-then-reconcile ("hold") mechanism, and a bug in releasing a hold is exactly the kind of thing that gets a caller stuck forever. Instead this mirrors Devii's own already-shipped mechanism exactly: read the 24h sum, compare, `raise HTTPException(429, ...)` if already at/over - a single `SELECT` and a conditional raise, nothing held, nothing to leak, structurally impossible to deadlock. The tradeoff is a small, bounded overshoot (at most a few concurrent in-flight calls' worth of cost past the cap before the next request sees the updated sum and blocks) - acceptable and industry-standard for a cost whose exact size isn't known until the call finishes, and it is the property actually being enforced: once tripped, every subsequent separate request stays blocked until the 24h window rolls off or an admin adjusts the rule.
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. CLI: `devplace gateway quota list|set|delete`.
## Image generation
`POST /openai/v1/images/generations` exposes an OpenAI-compatible image-generation endpoint. Clients send the generic model `molodetz-img-small` (`config.INTERNAL_IMAGE_MODEL`), which `handle_images` remaps to `gateway_image_model` exactly like chat remaps `molodetz` -> `gateway_model` (also remapped when `gateway_force_model` is on or the model is empty or `molodetz-img`). It defaults to OpenRouter's `black-forest-labs/flux-1.1-pro` at `https://openrouter.ai/api/v1/images/generations` (`config.IMAGE_*_DEFAULT`, $0.04 per image fallback). `handle_images` mirrors `handle_embeddings`: build the payload, forward via `_send`, and record one ledger row. The config fields are the **Images** group (`gateway_image_enabled` default on, `gateway_image_url`, `gateway_image_model`, `gateway_image_key`) plus the Pricing-group `gateway_image_price_per_call`. `effective_config()` falls the image key back to `gateway_api_key` then `OPENROUTER_API_KEY`. Usage is recorded with **`backend="image"`**; `usage.compute_cost` adds an `image` branch (flat per-call, native OpenRouter `cost` still preferred via `extract_image_usage`). `routing.image_overlay` resolves per-route provider/url/key and uses `price_input_per_m` as the per-image price. `routing.seed_default_image_routes()` (from `migrate_ai_gateway_settings`) idempotently seeds `molodetz-img-small` -> Flux on the `openrouter` provider when `OPENROUTER_API_KEY` is set. When `gateway_image_enabled` is off the endpoint returns 503 with no ledger row.

View File

@ -11,6 +11,7 @@ import httpx
from fastapi.responses import JSONResponse, Response, StreamingResponse
from devplacepy import stealth
from devplacepy.services.background import background
from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send
from devplacepy.services.openai_gateway.routing import (
@ -33,6 +34,19 @@ from devplacepy.services.openai_gateway.vision import VisionAugmenter, VisionCac
logger = logging.getLogger(__name__)
def _notify_gateway_status(is_open: bool) -> None:
from devplacepy.database import get_table
from devplacepy.utils import create_notification
message = (
"AI gateway circuit breaker opened - upstream calls are being rejected"
if is_open
else "AI gateway circuit breaker closed - upstream calls have resumed"
)
for admin in get_table("users").find(role="Admin"):
create_notification(admin["uid"], "system", message, "gateway", "/admin/services/openai")
def _fake_stream(data: dict, model: str, include_usage: bool = False):
chunk_id = data.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}")
created = data.get("created", int(time.time()))
@ -219,15 +233,24 @@ class GatewayRuntime:
self.last_latency_ms = int(timing["upstream_latency_ms"])
if exc is not None:
self.errors += 1
was_open = self._breaker.is_open
self._breaker.record_failure()
if not was_open and self._breaker.is_open:
background.submit(_notify_gateway_status, True)
log(f"{method} {url} connection failed after {attempts} attempt(s): {exc}")
return None, exc, timing
self.last_status = resp.status_code
if resp.status_code >= 500:
self.errors += 1
was_open = self._breaker.is_open
self._breaker.record_failure()
if not was_open and self._breaker.is_open:
background.submit(_notify_gateway_status, True)
else:
was_open = self._breaker.is_open
self._breaker.record_success()
if was_open and not self._breaker.is_open:
background.submit(_notify_gateway_status, False)
timing["retry_succeeded"] = attempts > 1
return resp, None, timing

View File

@ -0,0 +1,317 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.database import bump_cache_version, db, get_table, sync_local_cache
logger = logging.getLogger(__name__)
RULES_TABLE = "gateway_quota_rules"
CACHE_NAME = "gateway_quota"
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
FIELD_DEFAULT_USER = "gateway_default_user_daily_usd"
FIELD_DEFAULT_ADMIN = "gateway_default_admin_daily_usd"
FIELD_DEFAULT_GUEST = "gateway_default_guest_daily_usd"
FIELD_DEFAULT_INTERNAL = "gateway_default_internal_daily_usd"
FIELD_DEFAULT_KEY = "gateway_default_key_daily_usd"
_DEFAULT_FIELD_BY_KIND = {
"user": FIELD_DEFAULT_USER,
"admin": FIELD_DEFAULT_ADMIN,
"anonymous": FIELD_DEFAULT_GUEST,
"internal": FIELD_DEFAULT_INTERNAL,
"key": FIELD_DEFAULT_KEY,
}
_QUOTA_CACHE: dict = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def ensure_tables() -> None:
db.query(
"CREATE TABLE IF NOT EXISTS "
+ RULES_TABLE
+ " (id INTEGER PRIMARY KEY, uid TEXT, owner_kind TEXT, owner_id TEXT, "
"app_reference TEXT, limit_usd REAL DEFAULT 0, is_active INTEGER DEFAULT 1, "
"label TEXT, created_by TEXT, created_at TEXT, updated_at TEXT)"
)
try:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_quota_rules_uid ON "
+ RULES_TABLE
+ " (uid)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_gateway_quota_rules_lookup ON "
+ RULES_TABLE
+ " (owner_kind, owner_id, app_reference)"
)
except Exception as exc:
logger.warning("gateway quota rule index creation failed: %s", exc)
class QuotaRuleIn(BaseModel):
owner_kind: Optional[str] = None
owner_id: Optional[str] = Field(default=None, max_length=64)
app_reference: Optional[str] = Field(default=None, max_length=30)
limit_usd: float = Field(default=0.0, ge=0)
is_active: bool = True
label: str = Field(default="", max_length=200)
@field_validator("owner_kind")
@classmethod
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip().lower()
if value not in OWNER_KINDS:
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
return value
@field_validator("owner_id")
@classmethod
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
return value.strip()
@field_validator("app_reference")
@classmethod
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip()
if not APP_REFERENCE_PATTERN.match(value):
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
return value
@field_validator("label")
@classmethod
def _clean_label(cls, value: str) -> str:
return (value or "").strip()
@model_validator(mode="after")
def _require_a_dimension(self) -> "QuotaRuleIn":
if self.owner_kind is None and self.owner_id is None and self.app_reference is None:
raise ValueError(
"At least one of owner_kind, owner_id, or app_reference is required - "
"an unscoped cap belongs in the global default fields, not a rule"
)
return self
@dataclass(frozen=True)
class QuotaRule:
uid: str
owner_kind: Optional[str]
owner_id: Optional[str]
app_reference: Optional[str]
limit_usd: float
is_active: bool
label: str
created_by: str
created_at: str
updated_at: str
@property
def specificity(self) -> int:
return sum(
1 for v in (self.owner_kind, self.owner_id, self.app_reference) if v is not None
)
def matches(self, owner_kind: str, owner_id: str, app_reference: str) -> bool:
if not self.is_active:
return False
if self.owner_kind is not None and self.owner_kind != owner_kind:
return False
if self.owner_id is not None and self.owner_id != owner_id:
return False
if self.app_reference is not None and self.app_reference != app_reference:
return False
return True
def as_dict(self) -> dict:
return {
"uid": self.uid,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"app_reference": self.app_reference,
"limit_usd": self.limit_usd,
"is_active": self.is_active,
"label": self.label,
"created_by": self.created_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
"specificity": self.specificity,
}
def _row_to_rule(row: dict) -> QuotaRule:
return QuotaRule(
uid=str(row.get("uid") or ""),
owner_kind=row.get("owner_kind") or None,
owner_id=row.get("owner_id") or None,
app_reference=row.get("app_reference") or None,
limit_usd=float(row.get("limit_usd") or 0.0),
is_active=bool(row.get("is_active", 1)),
label=str(row.get("label") or ""),
created_by=str(row.get("created_by") or ""),
created_at=str(row.get("created_at") or ""),
updated_at=str(row.get("updated_at") or ""),
)
def _load() -> list[QuotaRule]:
sync_local_cache(CACHE_NAME, _QUOTA_CACHE)
if "rules" not in _QUOTA_CACHE:
rules: list[QuotaRule] = []
try:
if RULES_TABLE in db.tables:
for row in get_table(RULES_TABLE).all():
if row.get("uid"):
rules.append(_row_to_rule(row))
except Exception as exc:
logger.warning("gateway quota rule load failed: %s", exc)
_QUOTA_CACHE["rules"] = rules
return _QUOTA_CACHE["rules"]
class QuotaRuleStore:
def list(self) -> list[dict]:
rules = sorted(_load(), key=lambda r: (-r.specificity, r.created_at))
return [r.as_dict() for r in rules]
def get(self, uid: str) -> Optional[QuotaRule]:
if not uid:
return None
for rule in _load():
if rule.uid == uid:
return rule
return None
def count(self) -> int:
return len(_load())
def set(
self, payload: QuotaRuleIn, *, uid: Optional[str] = None, created_by: str = ""
) -> dict:
ensure_tables()
table = get_table(RULES_TABLE)
existing = table.find_one(uid=uid) if uid else None
record: dict = {
"owner_kind": payload.owner_kind,
"owner_id": payload.owner_id,
"app_reference": payload.app_reference,
"limit_usd": payload.limit_usd,
"is_active": 1 if payload.is_active else 0,
"label": payload.label,
"updated_at": _now(),
}
if existing:
record["uid"] = existing["uid"]
record["created_by"] = existing.get("created_by") or created_by
record["created_at"] = existing.get("created_at") or _now()
table.update({**record, "id": existing["id"]}, ["id"])
else:
record["uid"] = uid or uuid.uuid4().hex
record["created_by"] = created_by
record["created_at"] = _now()
table.insert(record)
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
saved = self.get(record["uid"])
return saved.as_dict() if saved else record
def remove(self, uid: str) -> bool:
uid = (uid or "").strip()
if not uid or RULES_TABLE not in db.tables:
return False
removed = int(get_table(RULES_TABLE).delete(uid=uid))
if removed:
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
return bool(removed)
quota_rule_store = QuotaRuleStore()
def default_limit(owner_kind: str, cfg: dict) -> float:
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
return float(cfg.get(field, 0.0) or 0.0)
def resolve(
owner_kind: str, owner_id: str, app_reference: str, cfg: dict
) -> tuple[float, tuple[Optional[str], Optional[str], Optional[str]], Optional[QuotaRule]]:
matches = [r for r in _load() if r.matches(owner_kind, owner_id, app_reference)]
if matches:
def _sort_key(rule: QuotaRule):
tie = float("inf") if rule.limit_usd == 0 else rule.limit_usd
return (-rule.specificity, tie)
best = sorted(matches, key=_sort_key)[0]
return best.limit_usd, (best.owner_kind, best.owner_id, best.app_reference), best
return default_limit(owner_kind, cfg), (owner_kind, owner_id, None), None
def resolve_for_owner(
owner_kind: str, owner_id: str, cfg: dict
) -> tuple[float, tuple[Optional[str], Optional[str], None], Optional[QuotaRule]]:
matches = [
r
for r in _load()
if r.app_reference is None and r.matches(owner_kind, owner_id, "")
]
if matches:
def _sort_key(rule: QuotaRule):
tie = float("inf") if rule.limit_usd == 0 else rule.limit_usd
return (-rule.specificity, tie)
best = sorted(matches, key=_sort_key)[0]
return best.limit_usd, (best.owner_kind, best.owner_id, None), best
return default_limit(owner_kind, cfg), (owner_kind, owner_id, None), None
def spent_24h(
owner_kind: Optional[str], owner_id: Optional[str], app_reference: Optional[str]
) -> float:
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
if GATEWAY_LEDGER not in db.tables:
return 0.0
clauses = ["created_at >= :cutoff"]
params: dict = {
"cutoff": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
}
if owner_kind is not None:
clauses.append("owner_kind = :owner_kind")
params["owner_kind"] = owner_kind
if owner_id is not None:
clauses.append("owner_id = :owner_id")
params["owner_id"] = owner_id
if app_reference is not None:
clauses.append("app_reference = :app_reference")
params["app_reference"] = app_reference
where = " AND ".join(clauses)
rows = list(
db.query(
f"SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM {GATEWAY_LEDGER} WHERE {where}",
**params,
)
)
return float(rows[0].get("spent") or 0.0) if rows else 0.0

View File

@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse
from devplacepy.database import get_int_setting
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway import config, quota
from devplacepy.services.openai_gateway.analytics import summary_metrics
from devplacepy.services.openai_gateway.gateway import GatewayRuntime
from devplacepy.services.openai_gateway.routing import model_store
@ -262,6 +262,58 @@ class GatewayService(BaseService):
"with this key. Clear it and restart to rotate.",
group="Access",
),
ConfigField(
quota.FIELD_DEFAULT_USER,
"Default per-user daily cap ($)",
type="float",
default=1.0,
minimum=0,
help="Rolling 24h cap applied to a signed-in member with no matching quota rule. "
"0 = unlimited. Overridable per user/app-reference on /admin/gateway.",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_ADMIN,
"Default per-admin daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to an administrator with no matching quota rule. "
"0 = unlimited (the default - admins are exempt unless a rule says otherwise).",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_GUEST,
"Default per-guest daily cap ($)",
type="float",
default=0.05,
minimum=0,
help="Rolling 24h cap applied to an unauthenticated caller with no matching quota "
"rule (only reachable when authentication is not required). 0 = unlimited.",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_INTERNAL,
"Default per-internal-key daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to calls authenticated with the auto-generated internal "
"key (DevPlace's own services: news, bots, Devii guests, correction/modifier). "
"0 = unlimited (the default - do not cap this without a matching quota rule, or "
"internal platform traffic will start failing).",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_KEY,
"Default per-access-key daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to calls authenticated with the static access key. "
"0 = unlimited by default (it is an admin-provisioned trusted secret).",
group="Quota",
),
ConfigField(
"gateway_price_cache_hit_per_m",
"Chat price cache-hit / 1M ($)",
@ -463,6 +515,37 @@ class GatewayService(BaseService):
return (kind, user.get("uid") or "unknown")
return ("anonymous", "anonymous")
def _audit_quota_exceeded(
self,
owner_kind: str,
owner_id: str,
app_reference: str,
spent: float,
limit: float,
rule,
) -> None:
from devplacepy.services.audit import record as audit
from devplacepy.services.openai_gateway.usage import audit_actor_for
actor_kind, actor_uid, actor_role = audit_actor_for(owner_kind, owner_id)
audit.record_system(
"ai.quota.exceeded",
actor_kind=actor_kind,
actor_uid=actor_uid,
actor_role=actor_role,
origin="api",
result="denied",
summary=f"AI gateway call by {owner_kind}/{owner_id} blocked - 24h quota reached",
metadata={
"owner_kind": owner_kind,
"owner_id": owner_id,
"app_reference": app_reference,
"spent_usd": round(spent, 6),
"limit_usd": limit,
"rule_uid": rule.uid if rule else None,
},
)
def _models_response(self) -> JSONResponse:
created = int(time.time())
seen: set = set()
@ -501,6 +584,17 @@ class GatewayService(BaseService):
)
if subpath == "models" and request.method == "GET":
return self._models_response()
limit, scope, rule = quota.resolve(owner[0], owner[1], app_reference, cfg)
if limit > 0:
spent = quota.spent_24h(*scope)
if spent >= limit:
self.log(
f"Rejected {owner[0]}:{owner[1]} app={app_reference}: "
f"quota exceeded (${spent:.4f}/${limit:.4f}"
f"{' rule ' + rule.uid if rule else ' default'})"
)
self._audit_quota_exceeded(owner[0], owner[1], app_reference, spent, limit, rule)
raise HTTPException(status_code=429, detail="AI gateway daily quota exceeded")
if subpath == "chat/completions" and request.method == "POST":
try:
body = await request.json()

View File

@ -11,6 +11,10 @@ export class GatewayAdmin {
this.modelForm = root.querySelector("#gw-model-form");
this.providerSelects = root.querySelectorAll("[data-provider-select]");
this.providers = [];
this.quotaRulesBody = root.querySelector("#gw-quota-rules");
this.quotaForm = root.querySelector("#gw-quota-form");
this.quotaCancelEdit = root.querySelector("#gw-quota-cancel-edit");
this.quotaRules = [];
}
async start() {
@ -46,6 +50,12 @@ export class GatewayAdmin {
this.modelForm.kind.addEventListener("change", () => this.syncKindFields());
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
this.quotaForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveQuotaRule();
});
this.quotaRulesBody.addEventListener("click", (event) => this.onQuotaRuleClick(event));
this.quotaCancelEdit.addEventListener("click", () => this.resetQuotaForm());
}
notify(message, type) {
@ -57,9 +67,10 @@ export class GatewayAdmin {
async reload() {
const providerCount = await this.loadProviders();
const modelCount = await this.loadModels();
const quotaCount = await this.loadQuotaRules();
const count = this.root.querySelector("#gw-count");
if (count) {
count.textContent = `${providerCount} providers, ${modelCount} routes`;
count.textContent = `${providerCount} providers, ${modelCount} routes, ${quotaCount} quota rules`;
}
}
@ -336,4 +347,115 @@ export class GatewayAdmin {
this.notify(err.message || "Delete failed", "error");
}
}
async loadQuotaRules() {
const data = await Http.getJson("/admin/gateway/quota-rules");
this.quotaRules = data.rules || [];
this.renderQuotaDefaults(data.defaults || {});
this.renderQuotaRules();
return this.quotaRules.length;
}
renderQuotaDefaults(defaults) {
const el = this.root.querySelector("#gw-quota-defaults");
if (!el) return;
const fmt = (v) => (Number(v) > 0 ? `$${Number(v).toFixed(2)}/24h` : "unlimited");
el.innerHTML = `
<strong>global defaults</strong> (from <a href="/admin/services">Services config</a>, apply per caller with no matching rule):
member <code class="gw-code">${fmt(defaults.user)}</code>,
admin <code class="gw-code">${fmt(defaults.admin)}</code>,
guest <code class="gw-code">${fmt(defaults.guest)}</code>,
internal <code class="gw-code">${fmt(defaults.internal)}</code>,
access key <code class="gw-code">${fmt(defaults.key)}</code>`;
}
scopeLabel(rule) {
const parts = [];
parts.push(rule.owner_kind ? `role=${rule.owner_kind}` : "role=any");
parts.push(rule.owner_id ? `user=${rule.owner_id}` : "user=any");
parts.push(rule.app_reference ? `app=${rule.app_reference}` : "app=any");
return parts.join(", ");
}
renderQuotaRules() {
if (!this.quotaRules.length) {
this.quotaRulesBody.innerHTML = `<tr><td colspan="6" class="admin-empty">No quota rules. Every caller is capped by the global defaults above.</td></tr>`;
return;
}
this.quotaRulesBody.innerHTML = this.quotaRules
.map((r) => {
const limit = Number(r.limit_usd) > 0 ? `$${Number(r.limit_usd).toFixed(2)}` : "unlimited";
const spent = `$${Number(r.spent_24h_usd || 0).toFixed(4)}`;
return `<tr>
<td><code class="gw-code">${this.escape(this.scopeLabel(r))}</code></td>
<td>${limit}</td>
<td>${spent}</td>
<td>${r.is_active ? "yes" : "no"}</td>
<td>${this.escape(r.label || "")}</td>
<td class="gw-actions">
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
</td>
</tr>`;
})
.join("");
}
async saveQuotaRule() {
const values = this.formValues(this.quotaForm);
const payload = {
owner_kind: values.owner_kind || null,
owner_id: values.owner_id || null,
app_reference: values.app_reference || null,
limit_usd: parseFloat(values.limit_usd) || 0,
is_active: values.is_active === "1",
label: values.label || "",
};
if (values.uid) payload.uid = values.uid;
try {
await Http.postJson("/admin/gateway/quota-rules", payload);
this.resetQuotaForm();
this.notify("Quota rule saved", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Save failed", "error");
}
}
fillQuotaForm(rule) {
const form = this.quotaForm;
form.uid.value = rule.uid || "";
form.owner_kind.value = rule.owner_kind || "";
form.owner_id.value = rule.owner_id || "";
form.app_reference.value = rule.app_reference || "";
form.limit_usd.value = rule.limit_usd || 0;
form.is_active.value = rule.is_active ? "1" : "0";
form.label.value = rule.label || "";
this.quotaCancelEdit.hidden = false;
form.owner_kind.scrollIntoView({ block: "center" });
}
resetQuotaForm() {
this.quotaForm.reset();
this.quotaForm.uid.value = "";
this.quotaCancelEdit.hidden = true;
}
onQuotaRuleClick(event) {
const editRaw = event.target.dataset.editQuota;
const delUid = event.target.dataset.delQuota;
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
if (delUid) this.deleteQuotaRule(delUid);
}
async deleteQuotaRule(uid) {
if (!(await this.confirmDelete("Delete this quota rule?"))) return;
try {
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
this.notify("Quota rule deleted", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Delete failed", "error");
}
}
}

View File

@ -77,6 +77,46 @@
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save model route</button></div>
</form>
</section>
<section class="gw-section">
<div class="gw-section-head">
<h3>Quota rules</h3>
</div>
<p class="gw-section-hint">Rolling 24h USD caps on <code class="gw-code">/openai/v1/*</code>. A rule scopes by any combination of role, specific user, and app label (the <code class="gw-code">X-App-Reference</code> header); the most specific active match wins, and a rule that omits a dimension pools spend across everyone matching it (e.g. an app-only rule caps that app's combined usage across all callers). With no matching rule, the global defaults below apply per caller. A limit of 0 means unlimited.</p>
<div class="gw-default" id="gw-quota-defaults" role="status" aria-live="polite"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Quota rules</caption>
<thead>
<tr><th scope="col">Scope</th><th scope="col">Limit / 24h</th><th scope="col">Spent 24h</th><th scope="col">Active</th><th scope="col">Label</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-quota-rules"></tbody>
</table>
</div>
<form class="gw-form" id="gw-quota-form" autocomplete="off" role="group" aria-label="Add or update quota rule">
<p class="gw-form-title">Add or update quota rule</p>
<input type="hidden" id="gw-quota-uid" name="uid" value="">
<div class="gw-field"><label for="gw-quota-owner-kind">Role</label>
<select id="gw-quota-owner-kind" name="owner_kind">
<option value="">any</option>
<option value="user">member</option>
<option value="admin">admin</option>
<option value="anonymous">guest (unauthenticated)</option>
<option value="internal">internal (DevPlace's own services)</option>
<option value="key">static access key</option>
</select>
</div>
<div class="gw-field"><label for="gw-quota-owner-id">Specific user uid</label><input type="text" id="gw-quota-owner-id" name="owner_id" placeholder="(optional) exact uid, blank = any"></div>
<div class="gw-field"><label for="gw-quota-app-reference">App reference</label><input type="text" id="gw-quota-app-reference" name="app_reference" placeholder="(optional) devplace-bots-v-1-0-0, blank = any"></div>
<div class="gw-field"><label for="gw-quota-limit">Limit / 24h ($)</label><input type="number" id="gw-quota-limit" name="limit_usd" min="0" step="0.01" value="0" title="0 = unlimited"></div>
<div class="gw-field"><label for="gw-quota-active">Active</label><select id="gw-quota-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label for="gw-quota-label">Label</label><input type="text" id="gw-quota-label" name="label" placeholder="(optional) admin note" maxlength="200"></div>
<div class="gw-form-actions">
<button type="submit" class="admin-btn admin-btn-primary">Save quota rule</button>
<button type="button" class="admin-btn admin-btn-sm" id="gw-quota-cancel-edit" hidden>Cancel edit</button>
</div>
</form>
</section>
</div>
{% endblock %}
{% block extra_js %}

View File

@ -42,6 +42,8 @@ Open `http://<host>:<PORT>`. Useful targets: `make docker-logs` (tail), `make do
The make targets always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Always update through them: a plain `docker compose up -d` drops the overlay and silently removes the docker socket and CLI the manager needs.
The healthcheck gives the app a 120s start window before any failure counts (`start_period: 120s` in `docker-compose.yml`). The full startup (DB init, background service registration, uvicorn workers) takes roughly 110s, so the start period must stay well above that. If you add heavyweight init work, verify the app boots within 120s or bump the start period to match.
## Updating
Code is bind-mounted, so most updates need no rebuild:

View File

@ -341,6 +341,17 @@
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.spent_usd) }} / {% if ai_quota.unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.limit_usd) }}{% endif %} &middot; {{ ai_quota.turns }} Devii turns</span>
</div>
</div>
{% if 'gateway_used_pct' in ai_quota %}
<div class="ai-quota" data-ai-quota-gateway>
<div class="ai-quota-bar">
<span class="ai-quota-fill {% if not ai_quota.gateway_unlimited and ai_quota.gateway_used_pct >= 90 %}ai-quota-danger{% endif %}" style="--quota-pct: {{ 0 if ai_quota.gateway_unlimited else ai_quota.gateway_used_pct }}%"></span>
</div>
<div class="ai-quota-meta">
<span class="ai-quota-pct">{% if ai_quota.gateway_unlimited %}No gateway daily cap{% else %}{{ ai_quota.gateway_used_pct }}% of gateway quota used{% endif %}</span>
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.gateway_spent_usd) }} / {% if ai_quota.gateway_unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.gateway_limit_usd) }}{% endif %} &middot; direct /openai/v1/* cap{% if ai_quota.gateway_pooled %}, shared pool{% endif %}</span>
</div>
</div>
{% endif %}
{% endif %}
<div class="ai-usage-body" data-ai-usage-body>
<p class="ai-usage-empty">Loading...</p>
@ -360,6 +371,16 @@
<span class="ai-quota-pct">{% if ai_quota.unlimited %}No daily AI spend cap{% else %}{{ ai_quota.used_pct }}% of your daily AI quota used{% endif %}</span>
</div>
</div>
{% if 'gateway_used_pct' in ai_quota %}
<div class="ai-quota" data-ai-quota-gateway>
<div class="ai-quota-bar">
<span class="ai-quota-fill {% if not ai_quota.gateway_unlimited and ai_quota.gateway_used_pct >= 90 %}ai-quota-danger{% endif %}" style="--quota-pct: {{ 0 if ai_quota.gateway_unlimited else ai_quota.gateway_used_pct }}%"></span>
</div>
<div class="ai-quota-meta">
<span class="ai-quota-pct">{% if ai_quota.gateway_unlimited %}No gateway daily cap{% else %}{{ ai_quota.gateway_used_pct }}% of your gateway quota used{% endif %}</span>
</div>
</div>
{% endif %}
{% else %}
<p class="ai-usage-empty">Quota unavailable.</p>
{% endif %}

View File

@ -24,7 +24,7 @@ services:
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
start_period: 120s
nginx:
build: