This commit is contained in:
2026-07-22 23:55:46 +02:00
parent 34f76aad65
commit 77f043640e
23 changed files with 993 additions and 11 deletions
+95 -1
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()