Files
devplacepy/devplacepy/services/devii/actions/catalog/gateway.py
T
retoor 571a0485c5
DevPlace CI / test (push) Failing after 58m59s
Fix circular import, primary-admin NULL trap, and add gateway quota reset
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.

Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.

Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.

Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.

Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.

Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.

Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.

Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.

Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.

Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00

198 lines
11 KiB
Python

# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action, Param
from ._shared import body, confirm, path
GATEWAY_ACTIONS: tuple[Action, ...] = (
Action(
name="gateway_providers",
method="GET",
path="/admin/gateway/providers",
summary="List OpenAI gateway providers (admin only)",
description=(
"Returns JSON: the named upstream providers used by model routes, plus the implicit "
"'default' provider read from the gateway service config."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_provider_set",
method="POST",
path="/admin/gateway/providers",
summary="Create or update a gateway provider (admin only)",
description=(
"Adds or updates a named upstream provider. base_url is the OpenAI-compatible "
"chat-completions endpoint; the embeddings and images endpoints are derived from it."
),
handler="http",
requires_admin=True,
params=(
body("name", "Provider name (letters, numbers, hyphen, underscore).", required=True),
body("base_url", "Chat-completions endpoint URL."),
body("api_key", "Upstream API key."),
Param(
name="is_active",
location="body",
description="Whether the provider is active ('1' or '0').",
required=False,
type="boolean",
),
),
),
Action(
name="gateway_provider_delete",
method="DELETE",
path="/admin/gateway/providers/{name}",
summary="Delete a gateway provider (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("name", "Provider name."), confirm()),
),
Action(
name="gateway_models",
method="GET",
path="/admin/gateway/models",
summary="List OpenAI gateway model routes (admin only)",
description=(
"Returns JSON: every source-model route with its provider, target model, kind "
"(chat/embed/image), optional vision model, context window, and per-model pricing economy "
"(including any tiered/off-peak pricing configured on it)."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_model_set",
method="POST",
path="/admin/gateway/models",
summary="Create or update a gateway model route (admin only)",
description=(
"Maps a requested source_model onto a provider + target_model, each with its own "
"pricing. kind is 'chat', 'embed', or 'image'. A vision_model adds image-to-text augmentation "
"for chat routes. Prices are USD per 1,000,000 tokens for chat/embed/vision; image routes "
"use price_input_per_m as a flat USD per generated image. Optionally, a route can also "
"charge a different (tier-2) rate once the request's input tokens exceed "
"context_tier_threshold_tokens, and/or apply a percentage discount during a fixed "
"UTC off-peak window - leave the tier2/off-peak fields unset to keep the flat rates "
"above at all times. off_peak_start_minute and off_peak_end_minute must be set "
"together (both or neither) or the call is rejected."
),
handler="http",
requires_admin=True,
params=(
body("source_model", "Model name clients request.", required=True),
body("provider", "Provider name, or blank for the default upstream."),
body("target_model", "Model name sent upstream.", required=True),
body("kind", "Route kind: 'chat', 'embed', or 'image'."),
body("vision_provider", "Provider for image description, or blank for the route provider."),
body("vision_model", "Vision model name (blank disables the merge)."),
Param(name="context_window", location="body", description="Max context tokens (0 = unknown).", required=False, type="integer"),
Param(name="price_cache_hit_per_m", location="body", description="USD per 1M cache-hit input tokens.", required=False, type="number"),
Param(name="price_cache_miss_per_m", location="body", description="USD per 1M cache-miss input tokens.", required=False, type="number"),
Param(name="price_output_per_m", location="body", description="USD per 1M output tokens.", required=False, type="number"),
Param(name="price_input_per_m", location="body", description="USD per 1M input tokens (embed/vision), or flat USD per image for image routes.", required=False, type="number"),
Param(name="context_tier_threshold_tokens", location="body", description="Input tokens above which tier-2 rates apply (0 disables tiering).", required=False, type="integer"),
Param(name="price_cache_hit_per_m_tier2", location="body", description="Tier-2 USD per 1M cache-hit input tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"),
Param(name="price_cache_miss_per_m_tier2", location="body", description="Tier-2 USD per 1M cache-miss input tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"),
Param(name="price_output_per_m_tier2", location="body", description="Tier-2 USD per 1M output tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"),
Param(name="price_input_per_m_tier2", location="body", description="Tier-2 USD per 1M input tokens, embed/vision (unset = keep tier-1 rate above threshold).", required=False, type="number"),
Param(name="off_peak_start_minute", location="body", description="Off-peak window start, UTC minutes since midnight (0-1439). Must be set together with off_peak_end_minute.", required=False, type="integer"),
Param(name="off_peak_end_minute", location="body", description="Off-peak window end, UTC minutes since midnight (0-1439). A value less than the start wraps past midnight.", required=False, type="integer"),
Param(name="off_peak_discount_pct", location="body", description="Percentage discount (0-100) applied to the active tier's rates during the off-peak window.", required=False, type="number"),
Param(name="is_active", location="body", description="Whether the route is active ('1' or '0').", required=False, type="boolean"),
),
),
Action(
name="gateway_model_delete",
method="DELETE",
path="/admin/gateway/models/{source_model}",
summary="Delete a gateway model route (admin only, confirmation required)",
handler="http",
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_reset",
method="POST",
path="/admin/gateway/quota-resets",
summary="Reset the rolling-24h AI gateway spend so a capped caller can call again (admin only)",
description=(
"Clears the counted spend for a scope without deleting any usage history, so the "
"cost analytics on /admin/ai-usage stay intact. Scope it exactly like a quota rule: "
"owner_kind (internal/key/user/admin/anonymous), a specific owner_id (user uid), and "
"app_reference (the X-App-Reference header). Leaving all three blank resets every "
"caller. A reset only clears spend recorded BEFORE it - new calls start counting "
"again immediately against the same limit. Use this when an app is stuck on "
"'AI gateway daily quota exceeded' and you want it running again without raising "
"its cap."
),
handler="http",
requires_admin=True,
params=(
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = every role."),
body("owner_id", "Specific user uid to scope by. Blank = every caller."),
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = every app."),
confirm(),
),
),
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()),
),
)