From 77f043640eb956d436ea9a1345e34624680094f1 Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 21 Jul 2026 09:48:37 +0200 Subject: [PATCH] yex --- CLAUDE.md | 4 + Dockerfile | 2 +- devplacepy/cli/gateway.py | 103 ++++++ devplacepy/cli/main.py | 2 + devplacepy/database/notifications.py | 1 + devplacepy/database/schema.py | 4 + devplacepy/docs_api/groups/admin.py | 47 +++ devplacepy/routers/admin/gateway_configs.py | 91 ++++- devplacepy/routers/profile/usage.py | 19 ++ .../services/devii/actions/catalog/gateway.py | 53 +++ .../services/devii/actions/dispatcher.py | 1 + devplacepy/services/devii/session/core.py | 17 +- devplacepy/services/devii/tasks/actions.py | 17 + devplacepy/services/devii/tasks/controller.py | 4 + devplacepy/services/openai_gateway/CLAUDE.md | 14 + devplacepy/services/openai_gateway/gateway.py | 23 ++ devplacepy/services/openai_gateway/quota.py | 317 ++++++++++++++++++ devplacepy/services/openai_gateway/service.py | 96 +++++- devplacepy/static/js/GatewayAdmin.js | 124 ++++++- devplacepy/templates/admin_gateway.html | 40 +++ .../templates/docs/production-deploy.html | 2 + devplacepy/templates/profile.html | 21 ++ docker-compose.yml | 2 +- 23 files changed, 993 insertions(+), 11 deletions(-) create mode 100644 devplacepy/cli/gateway.py create mode 100644 devplacepy/services/openai_gateway/quota.py diff --git a/CLAUDE.md b/CLAUDE.md index b4a4cefb..c12696d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,9 @@ devplace attachments prune # remove orphan attachment records/files devplace devii reset-quota # 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 # 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. diff --git a/Dockerfile b/Dockerfile index add00885..efc2a109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 '*'"] diff --git a/devplacepy/cli/gateway.py b/devplacepy/cli/gateway.py new file mode 100644 index 00000000..4fb22f7d --- /dev/null +++ b/devplacepy/cli/gateway.py @@ -0,0 +1,103 @@ +# retoor + +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) diff --git a/devplacepy/cli/main.py b/devplacepy/cli/main.py index fe7e28a3..66dbd83e 100644 --- a/devplacepy/cli/main.py +++ b/devplacepy/cli/main.py @@ -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 diff --git a/devplacepy/database/notifications.py b/devplacepy/database/notifications.py index e7af2a00..7dd26550 100644 --- a/devplacepy/database/notifications.py +++ b/devplacepy/database/notifications.py @@ -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)"}, ] diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 30fbabc0..5cf2ee19 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -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"]) diff --git a/devplacepy/docs_api/groups/admin.py b/devplacepy/docs_api/groups/admin.py index dc229c07..47da5621 100644 --- a/devplacepy/docs_api/groups/admin.py +++ b/devplacepy/docs_api/groups/admin.py @@ -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", diff --git a/devplacepy/routers/admin/gateway_configs.py b/devplacepy/routers/admin/gateway_configs.py index 6de4c256..957f0cd7 100644 --- a/devplacepy/routers/admin/gateway_configs.py +++ b/devplacepy/routers/admin/gateway_configs.py @@ -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}) diff --git a/devplacepy/routers/profile/usage.py b/devplacepy/routers/profile/usage.py index 32bdcd50..ef8f5d15 100644 --- a/devplacepy/routers/profile/usage.py +++ b/devplacepy/routers/profile/usage.py @@ -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 diff --git a/devplacepy/services/devii/actions/catalog/gateway.py b/devplacepy/services/devii/actions/catalog/gateway.py index ae644b42..3a2e7194 100644 --- a/devplacepy/services/devii/actions/catalog/gateway.py +++ b/devplacepy/services/devii/actions/catalog/gateway.py @@ -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()), + ), ) diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index bf00e782..f4a9148d 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -58,6 +58,7 @@ CONFIRM_REQUIRED = { "notification_reset", "gateway_provider_delete", "gateway_model_delete", + "gateway_quota_rule_delete", "email_account_delete", "email_delete_message", } diff --git a/devplacepy/services/devii/session/core.py b/devplacepy/services/devii/session/core.py index 40dcb3f7..c4d16200 100644 --- a/devplacepy/services/devii/session/core.py +++ b/devplacepy/services/devii/session/core.py @@ -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: diff --git a/devplacepy/services/devii/tasks/actions.py b/devplacepy/services/devii/tasks/actions.py index 72973f4d..898734e3 100644 --- a/devplacepy/services/devii/tasks/actions.py +++ b/devplacepy/services/devii/tasks/actions.py @@ -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", diff --git a/devplacepy/services/devii/tasks/controller.py b/devplacepy/services/devii/tasks/controller.py index 1062c4af..4c4486e0 100644 --- a/devplacepy/services/devii/tasks/controller.py +++ b/devplacepy/services/devii/tasks/controller.py @@ -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: diff --git a/devplacepy/services/openai_gateway/CLAUDE.md b/devplacepy/services/openai_gateway/CLAUDE.md index 6b58f004..7740214e 100644 --- a/devplacepy/services/openai_gateway/CLAUDE.md +++ b/devplacepy/services/openai_gateway/CLAUDE.md @@ -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. diff --git a/devplacepy/services/openai_gateway/gateway.py b/devplacepy/services/openai_gateway/gateway.py index d8b08758..7527dd6a 100644 --- a/devplacepy/services/openai_gateway/gateway.py +++ b/devplacepy/services/openai_gateway/gateway.py @@ -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 diff --git a/devplacepy/services/openai_gateway/quota.py b/devplacepy/services/openai_gateway/quota.py new file mode 100644 index 00000000..5455b357 --- /dev/null +++ b/devplacepy/services/openai_gateway/quota.py @@ -0,0 +1,317 @@ +# retoor + +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 diff --git a/devplacepy/services/openai_gateway/service.py b/devplacepy/services/openai_gateway/service.py index 1d780086..b7d15136 100644 --- a/devplacepy/services/openai_gateway/service.py +++ b/devplacepy/services/openai_gateway/service.py @@ -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() diff --git a/devplacepy/static/js/GatewayAdmin.js b/devplacepy/static/js/GatewayAdmin.js index 887f4356..63a5a5b6 100644 --- a/devplacepy/static/js/GatewayAdmin.js +++ b/devplacepy/static/js/GatewayAdmin.js @@ -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 = ` + global defaults (from Services config, apply per caller with no matching rule): + member ${fmt(defaults.user)}, + admin ${fmt(defaults.admin)}, + guest ${fmt(defaults.guest)}, + internal ${fmt(defaults.internal)}, + access key ${fmt(defaults.key)}`; + } + + 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 = `No quota rules. Every caller is capped by the global defaults above.`; + 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 ` + ${this.escape(this.scopeLabel(r))} + ${limit} + ${spent} + ${r.is_active ? "yes" : "no"} + ${this.escape(r.label || "")} + + + + + `; + }) + .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"); + } + } } diff --git a/devplacepy/templates/admin_gateway.html b/devplacepy/templates/admin_gateway.html index 9b305ae1..708cd119 100644 --- a/devplacepy/templates/admin_gateway.html +++ b/devplacepy/templates/admin_gateway.html @@ -77,6 +77,46 @@
+ +
+
+

Quota rules

+
+

Rolling 24h USD caps on /openai/v1/*. A rule scopes by any combination of role, specific user, and app label (the X-App-Reference 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.

+
+
+ + + + + + +
Quota rules
ScopeLimit / 24hSpent 24hActiveLabelActions
+
+
+

Add or update quota rule

+ +
+ +
+
+
+
+
+
+
+ + +
+
+
{% endblock %} {% block extra_js %} diff --git a/devplacepy/templates/docs/production-deploy.html b/devplacepy/templates/docs/production-deploy.html index 08f12f3f..86c0a143 100644 --- a/devplacepy/templates/docs/production-deploy.html +++ b/devplacepy/templates/docs/production-deploy.html @@ -42,6 +42,8 @@ Open `http://:`. 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: diff --git a/devplacepy/templates/profile.html b/devplacepy/templates/profile.html index d7b9359f..c24562b1 100644 --- a/devplacepy/templates/profile.html +++ b/devplacepy/templates/profile.html @@ -341,6 +341,17 @@ ${{ '%.4f'|format(ai_quota.spent_usd) }} / {% if ai_quota.unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.limit_usd) }}{% endif %} · {{ ai_quota.turns }} Devii turns + {% if 'gateway_used_pct' in ai_quota %} +
+
+ +
+
+ {% if ai_quota.gateway_unlimited %}No gateway daily cap{% else %}{{ ai_quota.gateway_used_pct }}% of gateway quota used{% endif %} + ${{ '%.4f'|format(ai_quota.gateway_spent_usd) }} / {% if ai_quota.gateway_unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.gateway_limit_usd) }}{% endif %} · direct /openai/v1/* cap{% if ai_quota.gateway_pooled %}, shared pool{% endif %} +
+
+ {% endif %} {% endif %}

Loading...

@@ -360,6 +371,16 @@ {% if ai_quota.unlimited %}No daily AI spend cap{% else %}{{ ai_quota.used_pct }}% of your daily AI quota used{% endif %}
+ {% if 'gateway_used_pct' in ai_quota %} +
+
+ +
+
+ {% if ai_quota.gateway_unlimited %}No gateway daily cap{% else %}{{ ai_quota.gateway_used_pct }}% of your gateway quota used{% endif %} +
+
+ {% endif %} {% else %}

Quota unavailable.

{% endif %} diff --git a/docker-compose.yml b/docker-compose.yml index aa2d33b1..c9aae2aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 10s + start_period: 120s nginx: build: