forked from retoor/devplacepy
Add OpenCode Zen support, model health/stats dashboard, and gateway fallback fixes
AI gateway: - Add a generic, admin-selectable `client_profile` field on gateway_providers (e.g. "opencode") so a provider needing special request headers (OpenCode Zen's client-identity spoofing) is configured like any other provider, not hardcoded by name. - Track per-(provider, model) reliability/speed/latency health in memory, seeded from the existing gateway_usage_ledger at startup - purely observational, never influences routing. - New Stats tab on /admin/gateway: request volume, latency, per-model breakdowns, and reliability weight, charted with a vendored Chart.js and devplace's own theme tokens. - Record which model a failed request actually fell back to (fallback_used_route), surfaced in the Recent Failures table. - Stop excluding context_length errors from fallback, and skip a primary attempt outright when its known context window is already too small for the estimated request size, going straight to the fallback. - gateway_usage_ledger's provider/fallback_used_route columns and indexes are ensured centrally in database/schema.py's init_db(), the single point of truth for this table's schema. - Non-OpenAI upstream routing and client-model passthrough; trust only the upstream's own X-Gateway-Model header for served-model attribution. Devii agent: - Fix a real lockup: plan/verify tools could be individually disabled via the admin tool toggles while still being required by the protocol gate, permanently bricking any task that needed tools. They can no longer be disabled, and the gate now also checks the tool is actually offered. - Fix compaction being silently calibrated for a 1M-token model while running a much smaller one: context budget is now percentage-based and the summarizer's own request is sized to fit the real model. - Give a specific, actionable retry message when plan()'s own arguments get cut off by the output limit, and tighten its schema to discourage overlong plans. Other: - Backup service: offload completed backups to a remote Hetzner Storage Box. - Container manager: fix orphan blob leaks from sync races, add a two-phase plan/execute `system prune` CLI command. - Admin gateway UI: replace the JS-rendered model/provider tables with server-rendered forms and pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhmEkvutuwtzFVcLbTrhdo
This commit is contained in:
@@ -49,3 +49,13 @@ NGINX_CACHE_MAX_SIZE=1g
|
||||
# Run the app container as this host user so shared files keep dev ownership.
|
||||
DEVPLACE_UID=1000
|
||||
DEVPLACE_GID=1000
|
||||
|
||||
# OpenCode Zen client identity (devplacepy/services/openai_gateway/opencode_zen.py).
|
||||
# Optional: both already default to these exact values, which match the real
|
||||
# opencode CLI's own headers - only override if opencode ships a new version
|
||||
# and Zen starts rejecting the old one. The provider's base URL, API key, and
|
||||
# which model(s) route to it are NOT set here - those live in the
|
||||
# gateway_providers/gateway_models tables, configured at /admin/gateway
|
||||
# (provider client profile "OpenCode Zen").
|
||||
# OPENCODE_CLIENT_NAME=cli
|
||||
# OPENCODE_CLIENT_USER_AGENT=opencode/1.18.29 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.15
|
||||
|
||||
@@ -638,10 +638,18 @@ def init_db():
|
||||
("app_reference", ""),
|
||||
("ttft_ms", 0.0),
|
||||
("inter_token_ms", 0.0),
|
||||
("provider", ""),
|
||||
("fallback_used_route", ""),
|
||||
):
|
||||
if not gateway_usage_ledger.has_column(column):
|
||||
gateway_usage_ledger.create_column_by_example(column, example)
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
|
||||
_index(
|
||||
db,
|
||||
"gateway_usage_ledger",
|
||||
"idx_gw_usage_provider_time",
|
||||
["provider", "created_at"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"gateway_usage_ledger",
|
||||
|
||||
@@ -264,6 +264,9 @@ async def lifespan(app: FastAPI):
|
||||
from devplacepy.push import ensure_certificates
|
||||
|
||||
ensure_certificates()
|
||||
from devplacepy.services.openai_gateway import model_health
|
||||
|
||||
model_health.seed_from_ledger()
|
||||
service_manager.register(NewsService())
|
||||
service_manager.register(BotsService())
|
||||
service_manager.register(GatewayService())
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from devplacepy.responses import json_error, respond, wants_json
|
||||
from devplacepy.schemas import (
|
||||
AdminGatewayModelFormOut,
|
||||
AdminGatewayOut,
|
||||
AdminGatewayProviderFormOut,
|
||||
AdminGatewayQuotaFormOut,
|
||||
)
|
||||
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 quota, routing
|
||||
from devplacepy.services.openai_gateway import model_stats_query, quota, routing
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin
|
||||
from devplacepy.utils import not_found, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
INDEX_URL = "/admin/gateway"
|
||||
TABS = ("models", "providers", "quota", "stats")
|
||||
|
||||
|
||||
def _default_provider_summary() -> dict:
|
||||
svc = service_manager.get_service("openai")
|
||||
@@ -32,10 +43,81 @@ def _default_provider_summary() -> dict:
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
return quota.scope_label(rule, fallback=rule.get("uid", ""))
|
||||
|
||||
|
||||
def _breadcrumbs(*extra: dict) -> list[dict]:
|
||||
trail = [
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Gateway", "url": INDEX_URL},
|
||||
]
|
||||
return trail + list(extra)
|
||||
|
||||
|
||||
def _seo(request: Request, title: str, breadcrumbs: list[dict]) -> dict:
|
||||
base = site_url(request)
|
||||
return base_seo_context(
|
||||
request,
|
||||
title=title,
|
||||
description="Manage OpenAI gateway providers, per-model routing, and quota rules.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=breadcrumbs,
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
|
||||
|
||||
def _first_error(exc: ValidationError) -> str:
|
||||
return exc.errors()[0].get("msg", "Invalid input")
|
||||
|
||||
|
||||
def _validation_error(exc: ValidationError) -> JSONResponse:
|
||||
first = exc.errors()[0]
|
||||
message = first.get("msg", "Invalid input")
|
||||
return JSONResponse({"ok": False, "error": message}, status_code=400)
|
||||
return JSONResponse({"ok": False, "error": _first_error(exc)}, status_code=400)
|
||||
|
||||
|
||||
def _bool_str(value) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
return "1" if str(value).strip().lower() in ("1", "true", "on", "yes") else "0"
|
||||
|
||||
|
||||
def _blank_to_none(value):
|
||||
value = "" if value is None else str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _time_to_minutes(value: str) -> Optional[int]:
|
||||
value = (value or "").strip()
|
||||
if not value or ":" not in value:
|
||||
return None
|
||||
hours_str, _, minutes_str = value.partition(":")
|
||||
try:
|
||||
return int(hours_str) * 60 + int(minutes_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _minutes_to_time(value) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
total = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
return f"{(total // 60) % 24:02d}:{total % 60:02d}"
|
||||
|
||||
|
||||
async def _payload(request: Request) -> dict:
|
||||
@@ -50,35 +132,54 @@ async def _payload(request: Request) -> dict:
|
||||
return {key: value for key, value in form.items()}
|
||||
|
||||
|
||||
@router.get("/gateway", response_class=HTMLResponse)
|
||||
async def gateway_config_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Gateway routing - Admin",
|
||||
description="Manage OpenAI gateway providers and per-model routing.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Gateway", "url": "/admin/gateway"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
def _error_response(
|
||||
request: Request, template: str, context: dict, message: str, status_code: int = 400
|
||||
):
|
||||
if wants_json(request):
|
||||
return json_error(status_code, message)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
template,
|
||||
{**context, "request": request, "error": message},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
# --- Index page (tabs: models / providers / quota) --------------------------
|
||||
|
||||
|
||||
@router.get("/gateway", response_class=HTMLResponse)
|
||||
async def gateway_config_page(request: Request, tab: str = "models"):
|
||||
admin = require_admin(request)
|
||||
if tab not in TABS:
|
||||
tab = "models"
|
||||
quota_rules = quota.quota_rule_store.list()
|
||||
for rule in quota_rules:
|
||||
rule["spent_24h_usd"] = round(
|
||||
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_gateway.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
**_seo(request, "Gateway routing - Admin", _breadcrumbs()),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"tab": tab,
|
||||
"providers": routing.provider_store.list(),
|
||||
"models": routing.model_store.list(),
|
||||
"quota_rules": quota_rules,
|
||||
"default_provider": _default_provider_summary(),
|
||||
"quota_defaults": _quota_defaults_summary(),
|
||||
"stats_ranges": list(model_stats_query.RANGE_SECONDS.keys()),
|
||||
},
|
||||
model=AdminGatewayOut,
|
||||
)
|
||||
|
||||
|
||||
# --- Providers: JSON API (Devii + programmatic clients) ---------------------
|
||||
|
||||
|
||||
@router.get("/gateway/providers")
|
||||
async def list_providers(request: Request):
|
||||
require_admin(request)
|
||||
@@ -92,13 +193,40 @@ async def list_providers(request: Request):
|
||||
|
||||
|
||||
@router.post("/gateway/providers")
|
||||
async def save_provider(request: Request):
|
||||
async def save_provider_json(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
try:
|
||||
payload = routing.ProviderIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
saved = _save_provider(request, admin, payload)
|
||||
return JSONResponse({"ok": True, "provider": saved})
|
||||
|
||||
|
||||
@router.delete("/gateway/providers/{name}")
|
||||
async def delete_provider(request: Request, name: str):
|
||||
admin = require_admin(request)
|
||||
if not _remove_provider(request, admin, name):
|
||||
return JSONResponse({"ok": False, "error": "Provider not found"}, status_code=404)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# --- Providers: backend-rendered pages ---------------------------------------
|
||||
|
||||
|
||||
def _provider_form_values(data: Optional[dict] = None) -> dict:
|
||||
data = data or {}
|
||||
return {
|
||||
"name": str(data.get("name", "")),
|
||||
"base_url": str(data.get("base_url", "")),
|
||||
"api_key": str(data.get("api_key", "")),
|
||||
"is_active": _bool_str(data.get("is_active", True)),
|
||||
"client_profile": str(data.get("client_profile", "")),
|
||||
}
|
||||
|
||||
|
||||
def _save_provider(request: Request, admin: dict, payload: "routing.ProviderIn") -> dict:
|
||||
saved = routing.provider_store.set(payload)
|
||||
audit.record(
|
||||
request,
|
||||
@@ -109,25 +237,130 @@ async def save_provider(request: Request):
|
||||
target_label=payload.name,
|
||||
summary=f"admin {admin['username']} saved gateway provider {payload.name}",
|
||||
)
|
||||
return JSONResponse({"ok": True, "provider": saved})
|
||||
return saved
|
||||
|
||||
|
||||
@router.delete("/gateway/providers/{name}")
|
||||
async def delete_provider(request: Request, name: str):
|
||||
admin = require_admin(request)
|
||||
def _remove_provider(request: Request, admin: dict, name: str) -> bool:
|
||||
existed = routing.provider_store.remove(name)
|
||||
if not existed:
|
||||
return JSONResponse({"ok": False, "error": "Provider not found"}, status_code=404)
|
||||
audit.record(
|
||||
if existed:
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.provider.delete",
|
||||
user=admin,
|
||||
target_type="gateway_provider",
|
||||
target_uid=name,
|
||||
target_label=name,
|
||||
summary=f"admin {admin['username']} deleted gateway provider {name}",
|
||||
)
|
||||
return existed
|
||||
|
||||
|
||||
@router.get("/gateway/providers/new", response_class=HTMLResponse)
|
||||
async def new_provider_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
return respond(
|
||||
request,
|
||||
"gateway.provider.delete",
|
||||
user=admin,
|
||||
target_type="gateway_provider",
|
||||
target_uid=name,
|
||||
target_label=name,
|
||||
summary=f"admin {admin['username']} deleted gateway provider {name}",
|
||||
"admin_gateway_provider_form.html",
|
||||
{
|
||||
**_seo(request, "Add provider - Gateway - Admin", _breadcrumbs({"name": "Add provider", "url": "/admin/gateway/providers/new"})),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": False,
|
||||
"form": _provider_form_values(),
|
||||
"error": None,
|
||||
},
|
||||
model=AdminGatewayProviderFormOut,
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/gateway/providers/new")
|
||||
async def create_provider_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
payload = routing.ProviderIn(**data)
|
||||
except ValidationError as exc:
|
||||
return _error_response(
|
||||
request,
|
||||
"admin_gateway_provider_form.html",
|
||||
{
|
||||
**_seo(request, "Add provider - Gateway - Admin", _breadcrumbs({"name": "Add provider", "url": "/admin/gateway/providers/new"})),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": False,
|
||||
"form": _provider_form_values(data),
|
||||
},
|
||||
_first_error(exc),
|
||||
)
|
||||
_save_provider(request, admin, payload)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=providers", status_code=302)
|
||||
|
||||
|
||||
@router.get("/gateway/providers/{name}/edit", response_class=HTMLResponse)
|
||||
async def edit_provider_page(request: Request, name: str):
|
||||
admin = require_admin(request)
|
||||
provider = routing.provider_store.get(name)
|
||||
if provider is None:
|
||||
raise not_found("Provider not found")
|
||||
return respond(
|
||||
request,
|
||||
"admin_gateway_provider_form.html",
|
||||
{
|
||||
**_seo(
|
||||
request,
|
||||
f"Edit {provider['name']} - Gateway - Admin",
|
||||
_breadcrumbs({"name": f"Edit {provider['name']}", "url": f"/admin/gateway/providers/{provider['name']}/edit"}),
|
||||
),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": True,
|
||||
"form": _provider_form_values(provider),
|
||||
"error": None,
|
||||
},
|
||||
model=AdminGatewayProviderFormOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/providers/{name}/edit")
|
||||
async def edit_provider_save(request: Request, name: str):
|
||||
admin = require_admin(request)
|
||||
existing = routing.provider_store.get(name)
|
||||
if existing is None:
|
||||
raise not_found("Provider not found")
|
||||
data = dict(await request.form())
|
||||
data["name"] = existing["name"]
|
||||
try:
|
||||
payload = routing.ProviderIn(**data)
|
||||
except ValidationError as exc:
|
||||
return _error_response(
|
||||
request,
|
||||
"admin_gateway_provider_form.html",
|
||||
{
|
||||
**_seo(
|
||||
request,
|
||||
f"Edit {existing['name']} - Gateway - Admin",
|
||||
_breadcrumbs({"name": f"Edit {existing['name']}", "url": f"/admin/gateway/providers/{existing['name']}/edit"}),
|
||||
),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": True,
|
||||
"form": _provider_form_values({**data, "name": existing["name"]}),
|
||||
},
|
||||
_first_error(exc),
|
||||
)
|
||||
_save_provider(request, admin, payload)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=providers", status_code=302)
|
||||
|
||||
|
||||
@router.post("/gateway/providers/{name}/delete")
|
||||
async def delete_provider_page(request: Request, name: str):
|
||||
admin = require_admin(request)
|
||||
if not _remove_provider(request, admin, name):
|
||||
raise not_found("Provider not found")
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=providers", status_code=302)
|
||||
|
||||
|
||||
# --- Model routes: JSON API (Devii + programmatic clients) ------------------
|
||||
|
||||
|
||||
@router.get("/gateway/models")
|
||||
@@ -142,24 +375,25 @@ async def list_models(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/models")
|
||||
async def save_model(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
try:
|
||||
payload = routing.ModelRouteIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
if payload.fallback_model:
|
||||
fallback_route = routing.model_store.get(payload.fallback_model)
|
||||
if fallback_route is None or fallback_route.kind != payload.kind:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Fallback model must be an existing model route of the same kind",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
@router.get("/gateway/provider-models")
|
||||
async def provider_models(request: Request, provider: str = ""):
|
||||
require_admin(request)
|
||||
models = await routing.fetch_provider_models(provider)
|
||||
if models is None:
|
||||
return json_error(404, "No model list available for this provider")
|
||||
return JSONResponse({"provider": provider, "models": models})
|
||||
|
||||
|
||||
def _check_fallback(payload: "routing.ModelRouteIn") -> Optional[str]:
|
||||
if not payload.fallback_model:
|
||||
return None
|
||||
fallback_route = routing.model_store.get(payload.fallback_model)
|
||||
if fallback_route is None or fallback_route.kind != payload.kind:
|
||||
return "Fallback model must be an existing model route of the same kind"
|
||||
return None
|
||||
|
||||
|
||||
def _save_model(request: Request, admin: dict, payload: "routing.ModelRouteIn") -> dict:
|
||||
saved = routing.model_store.set(payload)
|
||||
audit.record(
|
||||
request,
|
||||
@@ -173,41 +407,274 @@ async def save_model(request: Request):
|
||||
f"{payload.source_model} -> {payload.target_model}"
|
||||
),
|
||||
)
|
||||
return saved
|
||||
|
||||
|
||||
def _remove_model(request: Request, admin: dict, source_model: str) -> bool:
|
||||
existed = routing.model_store.remove(source_model)
|
||||
if existed:
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.model.delete",
|
||||
user=admin,
|
||||
target_type="gateway_model",
|
||||
target_uid=source_model,
|
||||
target_label=source_model,
|
||||
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
|
||||
)
|
||||
return existed
|
||||
|
||||
|
||||
@router.post("/gateway/models")
|
||||
async def save_model_json(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
try:
|
||||
payload = routing.ModelRouteIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
fallback_error = _check_fallback(payload)
|
||||
if fallback_error:
|
||||
return JSONResponse({"ok": False, "error": fallback_error}, status_code=400)
|
||||
saved = _save_model(request, admin, payload)
|
||||
return JSONResponse({"ok": True, "model": saved})
|
||||
|
||||
|
||||
@router.delete("/gateway/models/{source_model}")
|
||||
@router.delete("/gateway/models/{source_model:path}")
|
||||
async def delete_model(request: Request, source_model: str):
|
||||
admin = require_admin(request)
|
||||
existed = routing.model_store.remove(source_model)
|
||||
if not existed:
|
||||
if not _remove_model(request, admin, source_model):
|
||||
return JSONResponse({"ok": False, "error": "Model route not found"}, status_code=404)
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.model.delete",
|
||||
user=admin,
|
||||
target_type="gateway_model",
|
||||
target_uid=source_model,
|
||||
target_label=source_model,
|
||||
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 {}
|
||||
# --- Model routes: backend-rendered pages ------------------------------------
|
||||
|
||||
MODEL_FORM_DEFAULTS = {
|
||||
"source_model": "",
|
||||
"provider": "",
|
||||
"target_model": "",
|
||||
"kind": "chat",
|
||||
"vision_provider": "",
|
||||
"vision_model": "",
|
||||
"context_window": "0",
|
||||
"price_cache_hit_per_m": "0",
|
||||
"price_cache_miss_per_m": "0",
|
||||
"price_output_per_m": "0",
|
||||
"price_input_per_m": "0",
|
||||
"context_tier_threshold_tokens": "0",
|
||||
"price_cache_hit_per_m_tier2": "",
|
||||
"price_cache_miss_per_m_tier2": "",
|
||||
"price_output_per_m_tier2": "",
|
||||
"price_input_per_m_tier2": "",
|
||||
"off_peak_start": "",
|
||||
"off_peak_end": "",
|
||||
"off_peak_discount_pct": "0",
|
||||
"fallback_model": "",
|
||||
"is_active": "1",
|
||||
}
|
||||
|
||||
|
||||
def _model_form_values_from_route(route: dict) -> dict:
|
||||
def _num(key):
|
||||
return str(route.get(key, 0))
|
||||
|
||||
def _opt_num(key):
|
||||
value = route.get(key)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
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),
|
||||
"source_model": route.get("source_model", ""),
|
||||
"provider": route.get("provider", ""),
|
||||
"target_model": route.get("target_model", ""),
|
||||
"kind": route.get("kind", "chat"),
|
||||
"vision_provider": route.get("vision_provider", ""),
|
||||
"vision_model": route.get("vision_model", ""),
|
||||
"context_window": _num("context_window"),
|
||||
"price_cache_hit_per_m": _num("price_cache_hit_per_m"),
|
||||
"price_cache_miss_per_m": _num("price_cache_miss_per_m"),
|
||||
"price_output_per_m": _num("price_output_per_m"),
|
||||
"price_input_per_m": _num("price_input_per_m"),
|
||||
"context_tier_threshold_tokens": _num("context_tier_threshold_tokens"),
|
||||
"price_cache_hit_per_m_tier2": _opt_num("price_cache_hit_per_m_tier2"),
|
||||
"price_cache_miss_per_m_tier2": _opt_num("price_cache_miss_per_m_tier2"),
|
||||
"price_output_per_m_tier2": _opt_num("price_output_per_m_tier2"),
|
||||
"price_input_per_m_tier2": _opt_num("price_input_per_m_tier2"),
|
||||
"off_peak_start": _minutes_to_time(route.get("off_peak_start_minute")),
|
||||
"off_peak_end": _minutes_to_time(route.get("off_peak_end_minute")),
|
||||
"off_peak_discount_pct": _num("off_peak_discount_pct"),
|
||||
"fallback_model": route.get("fallback_model", ""),
|
||||
"is_active": _bool_str(route.get("is_active", True)),
|
||||
}
|
||||
|
||||
|
||||
def _rule_label(rule: dict) -> str:
|
||||
return quota.scope_label(rule, fallback=rule.get("uid", ""))
|
||||
def _model_form_values_from_submission(data: dict) -> dict:
|
||||
values = dict(MODEL_FORM_DEFAULTS)
|
||||
for key in values:
|
||||
if key in data:
|
||||
values[key] = str(data[key])
|
||||
values["is_active"] = _bool_str(data.get("is_active", "1"))
|
||||
return values
|
||||
|
||||
|
||||
def _model_payload_kwargs(data: dict) -> dict:
|
||||
def _num(key, default="0"):
|
||||
value = data.get(key, default)
|
||||
return value if str(value).strip() != "" else default
|
||||
|
||||
def _opt_num(key):
|
||||
value = str(data.get(key, "")).strip()
|
||||
return value or None
|
||||
|
||||
return {
|
||||
"source_model": data.get("source_model", ""),
|
||||
"provider": data.get("provider", ""),
|
||||
"target_model": data.get("target_model", ""),
|
||||
"kind": data.get("kind", "chat"),
|
||||
"vision_provider": data.get("vision_provider", ""),
|
||||
"vision_model": data.get("vision_model", ""),
|
||||
"context_window": _num("context_window"),
|
||||
"price_cache_hit_per_m": _num("price_cache_hit_per_m"),
|
||||
"price_cache_miss_per_m": _num("price_cache_miss_per_m"),
|
||||
"price_output_per_m": _num("price_output_per_m"),
|
||||
"price_input_per_m": _num("price_input_per_m"),
|
||||
"context_tier_threshold_tokens": _num("context_tier_threshold_tokens"),
|
||||
"price_cache_hit_per_m_tier2": _opt_num("price_cache_hit_per_m_tier2"),
|
||||
"price_cache_miss_per_m_tier2": _opt_num("price_cache_miss_per_m_tier2"),
|
||||
"price_output_per_m_tier2": _opt_num("price_output_per_m_tier2"),
|
||||
"price_input_per_m_tier2": _opt_num("price_input_per_m_tier2"),
|
||||
"off_peak_start_minute": _time_to_minutes(data.get("off_peak_start", "")),
|
||||
"off_peak_end_minute": _time_to_minutes(data.get("off_peak_end", "")),
|
||||
"off_peak_discount_pct": _num("off_peak_discount_pct"),
|
||||
"fallback_model": data.get("fallback_model", ""),
|
||||
"is_active": data.get("is_active", "1"),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_groups(exclude_source: str = "") -> list[dict]:
|
||||
by_kind: dict[str, list[str]] = {}
|
||||
for route in routing.model_store.list():
|
||||
if route["source_model"] == exclude_source:
|
||||
continue
|
||||
by_kind.setdefault(route["kind"], []).append(route["source_model"])
|
||||
return [{"kind": kind, "options": names} for kind, names in sorted(by_kind.items())]
|
||||
|
||||
|
||||
def _model_form_context(
|
||||
request: Request,
|
||||
admin: dict,
|
||||
*,
|
||||
is_edit: bool,
|
||||
form: dict,
|
||||
exclude_source: str = "",
|
||||
title: str,
|
||||
crumb_url: str,
|
||||
) -> dict:
|
||||
return {
|
||||
**_seo(request, title, _breadcrumbs({"name": title.split(" - ")[0], "url": crumb_url})),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": is_edit,
|
||||
"form": form,
|
||||
"providers": routing.provider_store.list(),
|
||||
"fallback_groups": _fallback_groups(exclude_source),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/gateway/models/new", response_class=HTMLResponse)
|
||||
async def new_model_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
context = _model_form_context(
|
||||
request,
|
||||
admin,
|
||||
is_edit=False,
|
||||
form=_model_form_values_from_submission({}),
|
||||
title="Add model route - Gateway - Admin",
|
||||
crumb_url="/admin/gateway/models/new",
|
||||
)
|
||||
return respond(request, "admin_gateway_model_form.html", {**context, "error": None}, model=AdminGatewayModelFormOut)
|
||||
|
||||
|
||||
@router.post("/gateway/models/new")
|
||||
async def create_model_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
data = dict(await request.form())
|
||||
context = _model_form_context(
|
||||
request,
|
||||
admin,
|
||||
is_edit=False,
|
||||
form=_model_form_values_from_submission(data),
|
||||
exclude_source=str(data.get("source_model", "")),
|
||||
title="Add model route - Gateway - Admin",
|
||||
crumb_url="/admin/gateway/models/new",
|
||||
)
|
||||
try:
|
||||
payload = routing.ModelRouteIn(**_model_payload_kwargs(data))
|
||||
except ValidationError as exc:
|
||||
return _error_response(request, "admin_gateway_model_form.html", context, _first_error(exc))
|
||||
fallback_error = _check_fallback(payload)
|
||||
if fallback_error:
|
||||
return _error_response(request, "admin_gateway_model_form.html", context, fallback_error)
|
||||
_save_model(request, admin, payload)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=models", status_code=302)
|
||||
|
||||
|
||||
@router.get("/gateway/models/{source_model:path}/edit", response_class=HTMLResponse)
|
||||
async def edit_model_page(request: Request, source_model: str):
|
||||
admin = require_admin(request)
|
||||
route = routing.model_store.get(source_model)
|
||||
if route is None:
|
||||
raise not_found("Model route not found")
|
||||
route_dict = route.__dict__.copy()
|
||||
context = _model_form_context(
|
||||
request,
|
||||
admin,
|
||||
is_edit=True,
|
||||
form=_model_form_values_from_route(route_dict),
|
||||
exclude_source=source_model,
|
||||
title=f"Edit {source_model} - Gateway - Admin",
|
||||
crumb_url=f"/admin/gateway/models/{source_model}/edit",
|
||||
)
|
||||
return respond(request, "admin_gateway_model_form.html", {**context, "error": None}, model=AdminGatewayModelFormOut)
|
||||
|
||||
|
||||
@router.post("/gateway/models/{source_model:path}/edit")
|
||||
async def edit_model_save(request: Request, source_model: str):
|
||||
admin = require_admin(request)
|
||||
existing = routing.model_store.get(source_model)
|
||||
if existing is None:
|
||||
raise not_found("Model route not found")
|
||||
data = dict(await request.form())
|
||||
data["source_model"] = source_model
|
||||
context = _model_form_context(
|
||||
request,
|
||||
admin,
|
||||
is_edit=True,
|
||||
form=_model_form_values_from_submission(data),
|
||||
exclude_source=source_model,
|
||||
title=f"Edit {source_model} - Gateway - Admin",
|
||||
crumb_url=f"/admin/gateway/models/{source_model}/edit",
|
||||
)
|
||||
try:
|
||||
payload = routing.ModelRouteIn(**_model_payload_kwargs(data))
|
||||
except ValidationError as exc:
|
||||
return _error_response(request, "admin_gateway_model_form.html", context, _first_error(exc))
|
||||
fallback_error = _check_fallback(payload)
|
||||
if fallback_error:
|
||||
return _error_response(request, "admin_gateway_model_form.html", context, fallback_error)
|
||||
_save_model(request, admin, payload)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=models", status_code=302)
|
||||
|
||||
|
||||
@router.post("/gateway/models/{source_model:path}/delete")
|
||||
async def delete_model_page(request: Request, source_model: str):
|
||||
admin = require_admin(request)
|
||||
if not _remove_model(request, admin, source_model):
|
||||
raise not_found("Model route not found")
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=models", status_code=302)
|
||||
|
||||
|
||||
# --- Quota rules: JSON API (Devii + programmatic clients) -------------------
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules")
|
||||
@@ -227,15 +694,9 @@ async def list_quota_rules(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
def _save_quota_rule(
|
||||
request: Request, admin: dict, payload: "quota.QuotaRuleIn", uid: Optional[str] = None
|
||||
) -> dict:
|
||||
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
@@ -253,6 +714,19 @@ async def save_quota_rule(request: Request):
|
||||
"is_active": saved["is_active"],
|
||||
},
|
||||
)
|
||||
return saved
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules")
|
||||
async def save_quota_rule_json(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 = _save_quota_rule(request, admin, payload, uid=uid)
|
||||
return JSONResponse({"ok": True, "rule": saved})
|
||||
|
||||
|
||||
@@ -302,3 +776,220 @@ async def delete_quota_rule(request: Request, uid: str):
|
||||
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# --- Quota rules: backend-rendered pages -------------------------------------
|
||||
|
||||
QUOTA_FORM_DEFAULTS = {
|
||||
"owner_kind": "",
|
||||
"owner_id": "",
|
||||
"app_reference": "",
|
||||
"limit_usd": "0",
|
||||
"is_active": "1",
|
||||
"label": "",
|
||||
}
|
||||
|
||||
|
||||
def _quota_form_values(data: Optional[dict] = None) -> dict:
|
||||
if not data:
|
||||
return dict(QUOTA_FORM_DEFAULTS)
|
||||
values = dict(QUOTA_FORM_DEFAULTS)
|
||||
for key in values:
|
||||
if key in data and data[key] is not None:
|
||||
values[key] = str(data[key])
|
||||
values["is_active"] = _bool_str(data.get("is_active", "1"))
|
||||
return values
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules/new", response_class=HTMLResponse)
|
||||
async def new_quota_rule_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
return respond(
|
||||
request,
|
||||
"admin_gateway_quota_form.html",
|
||||
{
|
||||
**_seo(request, "Add quota rule - Gateway - Admin", _breadcrumbs({"name": "Add quota rule", "url": "/admin/gateway/quota-rules/new"})),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": False,
|
||||
"form": _quota_form_values(),
|
||||
"error": None,
|
||||
},
|
||||
model=AdminGatewayQuotaFormOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules/new")
|
||||
async def create_quota_rule_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(
|
||||
owner_kind=_blank_to_none(data.get("owner_kind")),
|
||||
owner_id=_blank_to_none(data.get("owner_id")),
|
||||
app_reference=_blank_to_none(data.get("app_reference")),
|
||||
limit_usd=data.get("limit_usd", "0") or "0",
|
||||
is_active=data.get("is_active", "1"),
|
||||
label=data.get("label", ""),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return _error_response(
|
||||
request,
|
||||
"admin_gateway_quota_form.html",
|
||||
{
|
||||
**_seo(request, "Add quota rule - Gateway - Admin", _breadcrumbs({"name": "Add quota rule", "url": "/admin/gateway/quota-rules/new"})),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": False,
|
||||
"form": _quota_form_values(data),
|
||||
},
|
||||
_first_error(exc),
|
||||
)
|
||||
_save_quota_rule(request, admin, payload)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=quota", status_code=302)
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules/{uid}/edit", response_class=HTMLResponse)
|
||||
async def edit_quota_rule_page(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
rule = quota.quota_rule_store.get(uid)
|
||||
if rule is None:
|
||||
raise not_found("Quota rule not found")
|
||||
rule_dict = rule.as_dict()
|
||||
return respond(
|
||||
request,
|
||||
"admin_gateway_quota_form.html",
|
||||
{
|
||||
**_seo(
|
||||
request,
|
||||
"Edit quota rule - Gateway - Admin",
|
||||
_breadcrumbs({"name": "Edit quota rule", "url": f"/admin/gateway/quota-rules/{uid}/edit"}),
|
||||
),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": True,
|
||||
"uid": uid,
|
||||
"form": _quota_form_values(rule_dict),
|
||||
"error": None,
|
||||
},
|
||||
model=AdminGatewayQuotaFormOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules/{uid}/edit")
|
||||
async def edit_quota_rule_save(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
existing = quota.quota_rule_store.get(uid)
|
||||
if existing is None:
|
||||
raise not_found("Quota rule not found")
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(
|
||||
owner_kind=_blank_to_none(data.get("owner_kind")),
|
||||
owner_id=_blank_to_none(data.get("owner_id")),
|
||||
app_reference=_blank_to_none(data.get("app_reference")),
|
||||
limit_usd=data.get("limit_usd", "0") or "0",
|
||||
is_active=data.get("is_active", "1"),
|
||||
label=data.get("label", ""),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return _error_response(
|
||||
request,
|
||||
"admin_gateway_quota_form.html",
|
||||
{
|
||||
**_seo(
|
||||
request,
|
||||
"Edit quota rule - Gateway - Admin",
|
||||
_breadcrumbs({"name": "Edit quota rule", "url": f"/admin/gateway/quota-rules/{uid}/edit"}),
|
||||
),
|
||||
"user": admin,
|
||||
"admin_section": "gateway",
|
||||
"is_edit": True,
|
||||
"uid": uid,
|
||||
"form": _quota_form_values(data),
|
||||
},
|
||||
_first_error(exc),
|
||||
)
|
||||
_save_quota_rule(request, admin, payload, uid=uid)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=quota", status_code=302)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules/{uid}/delete")
|
||||
async def delete_quota_rule_page(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
|
||||
if not quota.quota_rule_store.remove(uid):
|
||||
raise not_found("Quota rule not found")
|
||||
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 RedirectResponse(url=f"{INDEX_URL}?tab=quota", status_code=302)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules/{uid}/reset")
|
||||
async def reset_quota_rule_page(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
existing = quota.quota_rule_store.get(uid)
|
||||
if existing is None:
|
||||
raise not_found("Quota rule not found")
|
||||
scope = quota.reset(
|
||||
quota.QuotaResetIn(
|
||||
owner_kind=existing.owner_kind,
|
||||
owner_id=existing.owner_id,
|
||||
app_reference=existing.app_reference,
|
||||
),
|
||||
created_by=admin["uid"],
|
||||
)
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota.reset",
|
||||
user=admin,
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} reset the gateway 24h spend for {label}",
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=f"{INDEX_URL}?tab=quota", status_code=302)
|
||||
|
||||
|
||||
# --- Stats: JSON API for the Stats tab's charts ------------------------------
|
||||
|
||||
|
||||
@router.get("/gateway/stats/data")
|
||||
async def gateway_stats_data(request: Request, range: str = "24h"):
|
||||
require_admin(request)
|
||||
try:
|
||||
return JSONResponse(model_stats_query.compute_summary(range))
|
||||
except ValueError:
|
||||
return json_error(400, f"Unknown range: {range!r}")
|
||||
|
||||
|
||||
@router.get("/gateway/stats/models")
|
||||
async def gateway_stats_models(request: Request):
|
||||
require_admin(request)
|
||||
return JSONResponse({"models": model_stats_query.list_known_models()})
|
||||
|
||||
|
||||
@router.get("/gateway/stats/model/{provider}/{model:path}")
|
||||
async def gateway_stats_model_detail(
|
||||
request: Request, provider: str, model: str, range: str = "24h"
|
||||
):
|
||||
require_admin(request)
|
||||
try:
|
||||
return JSONResponse(model_stats_query.compute_model_detail(provider, model, range))
|
||||
except ValueError:
|
||||
return json_error(400, f"Unknown range: {range!r}")
|
||||
|
||||
@@ -9,6 +9,7 @@ from devplacepy.utils import require_admin, not_found
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devii import tool_prefs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -71,6 +72,7 @@ async def service_detail(request: Request, name: str):
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
tool_groups = tool_prefs.group_overview() if name == "devii" else None
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"service_detail.html",
|
||||
@@ -79,6 +81,7 @@ async def service_detail(request: Request, name: str):
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"service": info,
|
||||
"tool_groups": tool_groups,
|
||||
"admin_section": "services",
|
||||
},
|
||||
)
|
||||
@@ -184,3 +187,34 @@ async def service_config(request: Request, name: str):
|
||||
if not result["ok"]:
|
||||
return JSONResponse(result, status_code=400)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.post("/devii/tools")
|
||||
async def devii_tools_config(request: Request):
|
||||
admin = require_admin(request)
|
||||
before = tool_prefs.disabled_tool_names()
|
||||
form = await request.form()
|
||||
enabled = set(form.getlist("enabled"))
|
||||
known = set(tool_prefs.GROUPS_BY_TOOL_NAME)
|
||||
after = known - enabled
|
||||
tool_prefs.set_disabled_tool_names(after)
|
||||
newly_disabled = sorted(after - before)
|
||||
newly_enabled = sorted(before - after)
|
||||
if newly_disabled or newly_enabled:
|
||||
audit.record(
|
||||
request,
|
||||
"service.devii_tools.update",
|
||||
user=admin,
|
||||
target_type="service",
|
||||
target_uid="devii",
|
||||
target_label="devii",
|
||||
old_value=f"{len(before)} disabled",
|
||||
new_value=f"{len(after)} disabled",
|
||||
summary=(
|
||||
f"admin {admin['username']} updated the Devii tool catalog "
|
||||
f"({len(newly_disabled)} disabled, {len(newly_enabled)} re-enabled)"
|
||||
),
|
||||
metadata={"disabled": newly_disabled, "enabled": newly_enabled},
|
||||
links=[audit.service_link("devii")],
|
||||
)
|
||||
return JSONResponse({"ok": True, "disabled_count": len(after), "total_count": len(known)})
|
||||
|
||||
@@ -124,6 +124,10 @@ from devplacepy.schemas.admin import (
|
||||
TrashItemOut,
|
||||
)
|
||||
from devplacepy.schemas.gateway import (
|
||||
AdminGatewayModelFormOut,
|
||||
AdminGatewayOut,
|
||||
AdminGatewayProviderFormOut,
|
||||
AdminGatewayQuotaFormOut,
|
||||
GatewayUsageOut,
|
||||
UserAiUsageOut,
|
||||
)
|
||||
|
||||
@@ -40,3 +40,37 @@ class UserAiUsageOut(_Out):
|
||||
by_backend: list = []
|
||||
hourly: list = []
|
||||
notes: dict = {}
|
||||
|
||||
|
||||
class AdminGatewayOut(_Out):
|
||||
tab: str = "models"
|
||||
providers: list = []
|
||||
models: list = []
|
||||
quota_rules: list = []
|
||||
default_provider: dict = {}
|
||||
quota_defaults: dict = {}
|
||||
stats_ranges: list = []
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminGatewayProviderFormOut(_Out):
|
||||
is_edit: bool = False
|
||||
form: dict = {}
|
||||
error: Optional[str] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminGatewayModelFormOut(_Out):
|
||||
is_edit: bool = False
|
||||
form: dict = {}
|
||||
providers: list = []
|
||||
fallback_groups: list = []
|
||||
error: Optional[str] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminGatewayQuotaFormOut(_Out):
|
||||
is_edit: bool = False
|
||||
form: dict = {}
|
||||
error: Optional[str] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
@@ -106,6 +106,10 @@ Site analytics is a **documented, admin-secured HTTP endpoint** - `GET /admin/an
|
||||
|
||||
The upstream model `deepseek-v4-flash` (what `deepseek-chat` routes to; default `gateway_model`) has a 1,048,576-token context and 384,000-token max output. All Devii size limits are **characters**, derived in `services/devii/config.py` from one source of truth: `CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS` (600,576 tokens), and `DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN` (3 chars/token = ~1.8M chars). At the conservative 3-chars/token estimate the worst-case input at compaction plus the full max output plus the system reserve sums to exactly the 1M window, so it can never overflow. `DEFAULT_MAX_RESPONSE_CHARS` (200,000) is the master chunk knob: every non-`chunks` tool result passes through `wrap_if_large(result, max_response_chars)` in `actions/dispatcher.py`, and `read_more` slices are clamped to it, so a file up to ~200KB returns in **one** read instead of paging in 12KB slices (the old read_more storm). `OUTPUT_CAP_CHARS` (`agentic/loop.py`, 400,000) must stay **above** `max_response_chars` or it would re-truncate a full chunk envelope. `ChunkStore` caches up to `STORE_MAX_CHARS` (8MB) per entry, `STORE_MAX_ENTRIES` (16) entries; `SUMMARY_INPUT_CAP` (`agentic/compaction.py`, 600,000) bounds what the summarizer ingests when compacting. When changing the upstream model, retune from `CONTEXT_WINDOW_TOKENS`/`MAX_OUTPUT_TOKENS` - everything else derives.
|
||||
|
||||
**Reactive recovery when the char-based budget above is wrong for the model actually serving the request (hard rule).** The proactive `context_compact_threshold` check is only ever as good as the assumed `CONTEXT_WINDOW_TOKENS` for whatever model answers a given call - a gateway reroute/fallback to a smaller-context backend silently invalidates it, and the agent would keep sending oversized requests until the provider itself rejects one with `400` (a real production case: provider capped at 131,072 tokens against a budget tuned for 1,048,576). `react_loop` (`agentic/loop.py`) does NOT just surface that error as `[model error] ...` and give up - `agentic/compaction.py` `is_context_length_error(exc)` recognizes it (OpenAI-style `error.code == "context_length_exceeded"`, or a phrase match on "maximum context length"/"reduce the length"/etc., since provider error shapes vary), and on a match the loop retries up to `MAX_CONTEXT_OVERFLOW_RETRIES` (5) times, geometrically halving two independent knobs each attempt: `context_keep_tail` (how many recent messages survive compaction verbatim) and a per-message character cap fed to `shrink_large_messages` (starting at `CONTEXT_OVERFLOW_MESSAGE_CAP_START`=200,000, floor `CONTEXT_OVERFLOW_MESSAGE_CAP_FLOOR`=4,000). **The shrink step is load-bearing and runs BEFORE `compact_messages` on every retry**: `compact_messages` only ever decides which whole messages survive vs. get summarized - it never touches an individual message's own size, so one oversized message (a big tool result) sitting in the always-kept tail defeats every retry no matter how far `keep_tail` drops, which is exactly the failure this fixes (a real incident: two retries of keep_tail-only reduction left the request essentially unchanged, 349,784 -> 350,050 input tokens, because the offending message never left the tail). Shrinking first also bounds what `compact_messages`'s own `summarize()` call has to ingest, so the summarizer itself is less likely to hit the same wall. Only after exhausting all 5 attempts does it fail closed with the provider's own message. This is the same reactive-at-the-chokepoint philosophy as the 401 stale-API-key self-healing above - never try to guess the right model beforehand, react to what the provider actually says, and don't stop at "fewer messages" when the real problem is "one message too big."
|
||||
|
||||
**`find_compaction_split` must accept any safe boundary, not only a `user`-role message (a second real incident of this same failure class).** `compact_messages` needs a split point where `messages[1:split]` (summarized away) and `messages[split:]` (kept verbatim) each stay internally self-contained - a tool result can never be separated from the assistant `tool_calls` message that produced it, or the provider rejects the request as malformed on the very next call. The original `find_compaction_split` enforced this by scanning backward from `len(messages) - keep_tail` for a `role == "user"` message and returning `1` (a **total no-op** - `compact_messages` then returns `messages` completely unchanged) if none was found before reaching index 1. That guard is correct for ordinary multi-turn chat, but a long single-turn agentic run (many consecutive `assistant` tool_calls + `tool` result pairs with no interleaved `user` message, e.g. a big scheduled task or a chat turn that just keeps calling tools) has **no `user`-role message anywhere in the scan range**, so both the proactive compaction (`context_size(messages) > context_compact_threshold`, checked every iteration) and the reactive overflow retry above silently did nothing at all, turn after turn, while `context_overflow_attempts` still climbed to `MAX_CONTEXT_OVERFLOW_RETRIES` and the loop still failed closed with the provider's raw 400 - "compaction" fired (visibly, as repeated `compact-overflow` trace events) but never actually shrank anything. The fix: track the closest safe fallback boundary (`role != "tool"`, i.e. `user` **or** a non-tool-calling point) seen during the same backward scan, and use it when no `user` message turns up - `assistant` is just as safe a split point as `user` since neither leaves an orphaned tool result on either side of the cut. Given the conversation always starts `[system, user, assistant, ...]`, this fallback is always found once `len(messages) >= keep_tail + 3` (the precondition `compact_messages` already checks before calling it), so the function can no longer degenerate to a permanent no-op. Regression-guarded by `tests/unit/services/devii/agentic/compaction.py` (`test_find_compaction_split_falls_back_to_a_non_tool_boundary_without_a_recent_user_message`, `test_find_compaction_split_never_lands_inside_a_tool_result_run`, `test_compact_messages_shrinks_a_tool_heavy_conversation_with_no_recent_user_message`) - each one fails against the pre-fix code with a synthetic tool-heavy, user-message-free conversation.
|
||||
|
||||
## Multi-worker service-lock routing
|
||||
|
||||
Hubs are per-process, so `/devii/ws` is served **only** by the worker that holds the background-service lock (`service_manager.owns_lock()`, set in `main.py` startup); a non-owner worker `close(4013)` (an application code in the private 4000-4999 range, reliably delivered as the browser `CloseEvent.code`). `DeviiSocket.js` recognises 4013 as "wrong worker, not yet settled" and fast-retries in ~200ms **silently** (no "disconnected, reconnecting..." notice), so with 2 prod workers the client converges on the owner in a fraction of a second instead of bouncing every 1500ms. The visible "connected." notice is emitted on the first server frame (`onReady`), never on raw socket open, so an accepted-then-4013-closed handshake on the wrong worker is invisible. The disabled/no-service path keeps the standard `1013` (a real, user-visible disconnect). The cap stays correct regardless because it reads the DB.
|
||||
@@ -178,6 +182,15 @@ Devii must never disclose the underlying model, provider, or any upstream URL -
|
||||
|
||||
`pyproject.toml [project.scripts]` ships `devii = "devplacepy.services.devii.cli:main"`. No new dependency (`httpx`/`dataset` already required). The md-clippy avatar is vendored under `static/vendor/md-clippy/`; its `index.js` must not set `globalThis.app` (it would clobber the DevPlace `app`) and its AI proxy is `/devii/clippy/ai/chat`. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process) and both `is_admin`/`is_primary_admin` `True` for the trusted local operator.
|
||||
|
||||
## Admin tool catalog control (`tool_prefs.py`, spare-context toggle)
|
||||
|
||||
Every tool schema Devii offers costs tokens on every single turn just by being listed in the `tools` array sent upstream, regardless of whether it is ever called - a real incident (`403,355` requested tokens against a `131,072`-token provider limit) had `53,571` tokens of that total in tool schemas alone, a fixed cost no amount of conversation compaction can touch. Admins configure which tools exist at all, site-wide, from the **Tools** tab on the Devii service page (`/admin/services/devii`, `service_detail.html`, conditional on `svc.name == "devii"` since this is Devii-specific, not part of the generic `BaseService` config machinery).
|
||||
|
||||
- **Grouping (`registry.py` `GROUPS`/`GROUP_LABELS`).** Every one of the ~358 catalog actions belongs to exactly one of ~43 groups, derived for free from the existing modular action-tuple structure (`POSTS_ACTIONS`, `ADMIN_ACTIONS`, `CONTAINER_ACTIONS`, ...) rather than annotating every individual `Action` - no per-tool code changed. `actions/catalog/__init__.py` exports its own internal `CATALOG_GROUPS` (the ~23 sub-domains folded into the flat `ACTIONS` tuple already built there); `registry.py` merges that with the ~20 top-level tuples (tasks, agentic, container, email, virtual_tool, ...) into one `GROUPS` dict and a parallel `GROUP_LABELS` dict of human-readable names. Two startup assertions (`_assert_group_coverage`, `_assert_group_labels`, alongside the existing `_assert_confirm_params`) fail the app at import time if a future action tuple is added to `CATALOG` without also being added to `GROUPS`/`GROUP_LABELS` - the same "closed under future additions" pattern as the moderation `REPORTABLE_TARGETS` registry test, just enforced by import-time `RuntimeError` instead of a pytest.
|
||||
- **Storage (`tool_prefs.py`).** One `site_settings` key, `FIELD_DISABLED_TOOLS` = `devii_disabled_tools` (JSON array of tool names), read/written via plain `get_setting`/`set_setting` (the standard 60s cross-worker settings cache, same propagation delay as every other Devii config field). `disabled_tool_names() -> frozenset[str]`; `set_disabled_tool_names(names)` filters the input against `GROUPS_BY_TOOL_NAME` (the flattened tool-name -> group-key index) so a stale or made-up name can never get persisted. `group_overview()` builds the admin template's context: one entry per group with its label, its tools (name/summary/admin-flags/disabled), and an `enabled_count`/`total_count` pair. `filter_disabled(schemas, disabled=None)` is the actual filter, reused at both enforcement chokepoints below.
|
||||
- **Enforcement is double, like every other Devii auth gate.** (1) `DeviiSession._builtin_tools()` (`session/core.py`) and the standalone `devii` CLI's tool-list build both call `filter_disabled(...)` on the schema list before anything else - a disabled tool never appears in what the model sees, on any channel (`main`, `docs`, or a task run), so the "docs" channel's single `search_docs` tool disappearing if an admin disables the "Docs Search" group is a deliberate, correct consequence, not a bug. (2) `Dispatcher.dispatch` (`actions/dispatcher.py`) independently checks `name in disabled_tool_names()` (a lazy in-function import, matching how `registry.py` itself lazily imports `dispatcher` to avoid the reverse circular edge - `dispatcher.py` must never import `tool_prefs`/`registry` at module top) before the auth checks, refusing with a new `ToolDisabledError` (`errors.py`, code `tool_disabled`) even for a primary administrator - so a model that still remembers an old tool name from earlier in a long conversation (a real thing models do) cannot slip past the hidden schema and execute it anyway. Both paths are audited identically to every other denial (`security.authz.denied`, reason `"disabled by administrator"`).
|
||||
- **The admin UI is bespoke, not the generic `ConfigField` form** - 358 individual checkboxes would never fit that auto-rendered flat layout. `routers/admin/services.py` extends `service_detail` to pass `tool_groups = tool_prefs.group_overview()` only when `name == "devii"`, and adds `POST /admin/services/devii/tools` (audited as `service.devii_tools.update`, category `service` via the existing prefix, key registered in `events.md`): checked `enabled` values become the new enabled set, everything else in `GROUPS_BY_TOOL_NAME` becomes disabled - so an admin only ever submits what should stay ON. The template (`service_detail.html` Tools tab) renders each group as a native `<details>` with a tri-state header checkbox (checked/unchecked/indeterminate); `static/js/DeviiToolsConfig.js` wires the group checkbox to bulk-toggle its members, keeps the header count and the page-wide summary live, and drives a client-side search box that filters tools by name/description and auto-expands matching groups. **Load-bearing CSS gotcha**: the global `input, textarea, select { width: 100%; ... }` rule (`base.css`) hits `<input type="checkbox">` too, stretching it to fill the flex row - the established fix already used by `.auth-options input[type="checkbox"]` (`width: auto; accent-color: var(--accent);`) had to be repeated for `.devii-tools-group-checkbox`/`.devii-tools-item input[type="checkbox"]`, or the whole group-header layout silently breaks (checkbox eats ~80% of the row, label wraps into a narrow column) with no console error to point at it.
|
||||
|
||||
## Devii user-defined ("virtual") tools
|
||||
|
||||
Users invent new Devii tools in natural language ("when I say woeii, do Y"); each is stored per-owner and added to Devii's live LLM tool list, and when called its handler **re-prompts Devii itself** (a self-eval sub-agent) with the stored prompt plus the user's single free-form `input`. Full CRUD is Devii-only (`tool_create`/`tool_list`/`tool_get`/`tool_update`/`tool_delete`, `handler="virtual_tool"`).
|
||||
|
||||
@@ -27,32 +27,36 @@ from .social import SOCIAL_ACTIONS
|
||||
from .tools import TOOLS_ACTIONS
|
||||
from .uploads import UPLOAD_ACTIONS
|
||||
|
||||
ACTIONS: tuple[Action, ...] = (
|
||||
AUTH_ACTIONS
|
||||
+ POSTS_ACTIONS
|
||||
+ COMMENTS_ACTIONS
|
||||
+ PROJECTS_ACTIONS
|
||||
+ PROJECT_FILE_ACTIONS
|
||||
+ JOB_ACTIONS
|
||||
+ TOOLS_ACTIONS
|
||||
+ PROFILE_ACTIONS
|
||||
+ MESSAGE_ACTIONS
|
||||
+ NOTIFICATION_ACTIONS
|
||||
+ ENGAGEMENT_ACTIONS
|
||||
+ SOCIAL_ACTIONS
|
||||
+ ISSUE_ACTIONS
|
||||
+ GIST_ACTIONS
|
||||
+ NEWS_ACTIONS
|
||||
+ UPLOAD_ACTIONS
|
||||
+ ADMIN_ACTIONS
|
||||
+ DBAPI_ACTIONS
|
||||
+ GATEWAY_ACTIONS
|
||||
+ GAME_ACTIONS
|
||||
+ QUIZ_ACTIONS
|
||||
+ BATTLE_ACTIONS
|
||||
+ MODERATION_ACTIONS
|
||||
CATALOG_GROUPS: dict[str, tuple[Action, ...]] = {
|
||||
"auth": AUTH_ACTIONS,
|
||||
"posts": POSTS_ACTIONS,
|
||||
"comments": COMMENTS_ACTIONS,
|
||||
"projects": PROJECTS_ACTIONS,
|
||||
"project_files": PROJECT_FILE_ACTIONS,
|
||||
"jobs": JOB_ACTIONS,
|
||||
"tools": TOOLS_ACTIONS,
|
||||
"profile": PROFILE_ACTIONS,
|
||||
"messages": MESSAGE_ACTIONS,
|
||||
"notifications": NOTIFICATION_ACTIONS,
|
||||
"engagement": ENGAGEMENT_ACTIONS,
|
||||
"social": SOCIAL_ACTIONS,
|
||||
"issues": ISSUE_ACTIONS,
|
||||
"gists": GIST_ACTIONS,
|
||||
"news": NEWS_ACTIONS,
|
||||
"uploads": UPLOAD_ACTIONS,
|
||||
"admin": ADMIN_ACTIONS,
|
||||
"dbapi": DBAPI_ACTIONS,
|
||||
"gateway": GATEWAY_ACTIONS,
|
||||
"game": GAME_ACTIONS,
|
||||
"quizzes": QUIZ_ACTIONS,
|
||||
"battles": BATTLE_ACTIONS,
|
||||
"moderation": MODERATION_ACTIONS,
|
||||
}
|
||||
|
||||
ACTIONS: tuple[Action, ...] = tuple(
|
||||
action for group in CATALOG_GROUPS.values() for action in group
|
||||
)
|
||||
|
||||
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
|
||||
|
||||
__all__ = ["ACTIONS", "PLATFORM_CATALOG"]
|
||||
__all__ = ["ACTIONS", "PLATFORM_CATALOG", "CATALOG_GROUPS"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action, Param
|
||||
from ._shared import body, confirm, path
|
||||
from ._shared import body, confirm, path, query
|
||||
|
||||
|
||||
GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
@@ -67,6 +67,24 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="gateway_provider_models",
|
||||
method="GET",
|
||||
path="/admin/gateway/provider-models",
|
||||
summary="List the real models a gateway provider offers, if it publishes one (admin only)",
|
||||
description=(
|
||||
"Probes the given provider's own OpenAI-compatible model-listing endpoint "
|
||||
"(derived from its base_url) and returns the model ids it reports. Pass a "
|
||||
"blank provider to probe the default upstream instead of a named provider. "
|
||||
"Returns 404 when the provider is unknown or does not answer with a usable "
|
||||
"model list - in that case target_model on gateway_model_set must be typed "
|
||||
"freely rather than picked from this list."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
params=(query("provider", "Provider name, or blank for the default upstream."),),
|
||||
),
|
||||
Action(
|
||||
name="gateway_model_set",
|
||||
method="POST",
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..config import Settings
|
||||
from ..errors import (
|
||||
AuthRequiredError,
|
||||
DeviiError,
|
||||
ToolDisabledError,
|
||||
ToolInputError,
|
||||
error_result,
|
||||
unexpected_result,
|
||||
@@ -418,6 +419,15 @@ class Dispatcher:
|
||||
|
||||
logger.info("Dispatch %s args=%s", name, list(arguments))
|
||||
try:
|
||||
from ..tool_prefs import disabled_tool_names
|
||||
|
||||
if name in disabled_tool_names():
|
||||
self._audit_denied(name, "disabled by administrator", arguments)
|
||||
raise ToolDisabledError(
|
||||
"This tool has been disabled by an administrator to reduce "
|
||||
"context usage. Do not retry it; tell the user it is unavailable.",
|
||||
tool=name,
|
||||
)
|
||||
if action.requires_auth and not self._client.authenticated:
|
||||
self._audit_denied(name, "authentication required", arguments)
|
||||
raise AuthRequiredError(
|
||||
|
||||
@@ -30,13 +30,16 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
|
||||
arg("goal", "One-line restatement of the user's goal.", required=True),
|
||||
arg(
|
||||
"steps",
|
||||
"Ordered list of step objects, each with id, action, depends_on.",
|
||||
"Ordered list of step objects, each with id, action, depends_on. Keep "
|
||||
"it short: at most 6 steps, each action under ~15 words - this is a "
|
||||
"quick outline, not a detailed spec, and an overlong plan can get cut "
|
||||
"off by the output length limit.",
|
||||
required=True,
|
||||
kind="array",
|
||||
),
|
||||
arg(
|
||||
"success_criteria",
|
||||
"Concrete criteria for declaring the task complete.",
|
||||
"Concrete criteria for declaring the task complete, in one short sentence.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
|
||||
@@ -6,11 +6,19 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..errors import LLMError
|
||||
from ..text import normalize_newlines
|
||||
|
||||
logger = logging.getLogger("devii.agentic.compaction")
|
||||
|
||||
SUMMARY_INPUT_CAP = 600_000
|
||||
CONTEXT_LENGTH_ERROR_CODES = {"context_length_exceeded"}
|
||||
CONTEXT_LENGTH_ERROR_PHRASES = (
|
||||
"maximum context length",
|
||||
"context length exceeded",
|
||||
"reduce the length",
|
||||
"context_length_exceeded",
|
||||
)
|
||||
SUMMARY_PROMPT = (
|
||||
"Summarize the following assistant conversation segment as a concise factual log of "
|
||||
"actions taken, tools called, entities created or changed, conclusions reached, and "
|
||||
@@ -25,15 +33,51 @@ def context_size(messages: list[dict[str, Any]]) -> int:
|
||||
return len(json.dumps(messages, default=str))
|
||||
|
||||
|
||||
def shrink_large_messages(
|
||||
messages: list[dict[str, Any]], max_message_chars: int, skip_leading: int = 1
|
||||
) -> bool:
|
||||
shrunk = False
|
||||
for message in messages[skip_leading:]:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str) and len(content) > max_message_chars:
|
||||
removed = len(content) - max_message_chars
|
||||
message["content"] = (
|
||||
content[:max_message_chars]
|
||||
+ f"\n...[truncated {removed} more chars to fit the model's context window]"
|
||||
)
|
||||
shrunk = True
|
||||
return shrunk
|
||||
|
||||
|
||||
def is_context_length_error(exc: LLMError) -> bool:
|
||||
if exc.details.get("status") != 400:
|
||||
return False
|
||||
body = str(exc.details.get("body") or "")
|
||||
haystack = f"{exc.message} {body}".lower()
|
||||
if any(phrase in haystack for phrase in CONTEXT_LENGTH_ERROR_PHRASES):
|
||||
return True
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
error = parsed.get("error") if isinstance(parsed, dict) else None
|
||||
code = str((error or {}).get("code") or "").lower() if isinstance(error, dict) else ""
|
||||
return code in CONTEXT_LENGTH_ERROR_CODES
|
||||
|
||||
|
||||
def find_compaction_split(messages: list[dict[str, Any]], keep_tail: int) -> int:
|
||||
if len(messages) <= keep_tail:
|
||||
return 1
|
||||
candidate = len(messages) - keep_tail
|
||||
fallback = 0
|
||||
while candidate > 1:
|
||||
if messages[candidate].get("role") == "user":
|
||||
role = messages[candidate].get("role")
|
||||
if role == "user":
|
||||
return candidate
|
||||
if role != "tool" and fallback == 0:
|
||||
fallback = candidate
|
||||
candidate -= 1
|
||||
return 1
|
||||
return fallback or 1
|
||||
|
||||
|
||||
def _segment_plain(messages: list[dict[str, Any]]) -> str:
|
||||
@@ -68,7 +112,10 @@ def _segment_plain(messages: list[dict[str, Any]]) -> str:
|
||||
|
||||
|
||||
async def compact_messages(
|
||||
llm: Any, messages: list[dict[str, Any]], keep_tail: int
|
||||
llm: Any,
|
||||
messages: list[dict[str, Any]],
|
||||
keep_tail: int,
|
||||
max_summary_chars: int = SUMMARY_INPUT_CAP,
|
||||
) -> list[dict[str, Any]]:
|
||||
if len(messages) < keep_tail + 3:
|
||||
return messages
|
||||
@@ -81,9 +128,9 @@ async def compact_messages(
|
||||
if not middle:
|
||||
return messages
|
||||
|
||||
segment = _segment_plain(middle)[:SUMMARY_INPUT_CAP]
|
||||
segment = _segment_plain(middle)[:max_summary_chars]
|
||||
if not segment.strip():
|
||||
segment = json.dumps(middle, default=str)[:SUMMARY_INPUT_CAP]
|
||||
segment = json.dumps(middle, default=str)[:max_summary_chars]
|
||||
try:
|
||||
summary = await llm.summarize(SUMMARY_PROMPT + segment)
|
||||
except Exception: # noqa: BLE001 - compaction must never break the loop
|
||||
|
||||
@@ -11,13 +11,21 @@ from ..config import Settings
|
||||
from ..errors import LLMError
|
||||
from ..chunks import reset_store, set_store
|
||||
from ..cost import reset_tracker, set_tracker
|
||||
from .compaction import compact_messages, context_size
|
||||
from .compaction import (
|
||||
compact_messages,
|
||||
context_size,
|
||||
is_context_length_error,
|
||||
shrink_large_messages,
|
||||
)
|
||||
from .state import AgentState, reset_state, set_state
|
||||
|
||||
logger = logging.getLogger("devii.agentic.loop")
|
||||
|
||||
TraceCallback = Callable[[str, str, str], None]
|
||||
OUTPUT_CAP_CHARS = 400_000
|
||||
MAX_CONTEXT_OVERFLOW_RETRIES = 5
|
||||
CONTEXT_OVERFLOW_MESSAGE_CAP_START = 200_000
|
||||
CONTEXT_OVERFLOW_MESSAGE_CAP_FLOOR = 4_000
|
||||
|
||||
REFLECTION_TRIGGER = (
|
||||
"[reflection-trigger] One or more tool calls returned an error. Call reflect() with the "
|
||||
@@ -144,14 +152,24 @@ async def _run_tool_call(dispatcher: Any, call: dict[str, Any]) -> str:
|
||||
else raw_arguments
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
if name == "plan":
|
||||
advice = (
|
||||
"Resend plan() with far fewer steps and shorter text per field - "
|
||||
"a one-line goal, 3-6 short steps, and a brief success_criteria. "
|
||||
"A long, detailed plan is what caused this cutoff."
|
||||
)
|
||||
else:
|
||||
advice = (
|
||||
"Emit only one write tool call per turn (do not batch several file writes "
|
||||
"into a single response) and resend this one call on its own."
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "tool_input_truncated",
|
||||
"message": (
|
||||
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
|
||||
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
|
||||
"Emit only one write tool call per turn (do not batch several file writes into a "
|
||||
"single response) and resend this one call on its own."
|
||||
f"{advice}"
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -187,6 +205,10 @@ async def react_loop(
|
||||
cost_token = set_tracker(cost_tracker) if cost_tracker is not None else None
|
||||
chunk_token = set_store(chunk_store) if chunk_store is not None else None
|
||||
final_content = ""
|
||||
context_overflow_attempts = 0
|
||||
offered_tool_names = {tool["function"]["name"] for tool in tools}
|
||||
plan_required = plan_required and "plan" in offered_tool_names
|
||||
verify_required = verify_required and "verify" in offered_tool_names
|
||||
try:
|
||||
while state.iteration < max_iterations:
|
||||
state.iteration += 1
|
||||
@@ -194,12 +216,45 @@ async def react_loop(
|
||||
if context_size(messages) > settings.context_compact_threshold:
|
||||
trace("compact")
|
||||
messages[:] = await compact_messages(
|
||||
llm, messages, settings.context_keep_tail
|
||||
llm,
|
||||
messages,
|
||||
settings.context_keep_tail,
|
||||
settings.context_summary_max_chars,
|
||||
)
|
||||
|
||||
try:
|
||||
message = await llm.complete(messages, tools)
|
||||
except LLMError as exc:
|
||||
if (
|
||||
is_context_length_error(exc)
|
||||
and context_overflow_attempts < MAX_CONTEXT_OVERFLOW_RETRIES
|
||||
):
|
||||
context_overflow_attempts += 1
|
||||
trace("compact-overflow")
|
||||
shrink_factor = 2 ** (context_overflow_attempts - 1)
|
||||
message_cap = max(
|
||||
CONTEXT_OVERFLOW_MESSAGE_CAP_FLOOR,
|
||||
CONTEXT_OVERFLOW_MESSAGE_CAP_START // shrink_factor,
|
||||
)
|
||||
keep_tail = max(2, settings.context_keep_tail // shrink_factor)
|
||||
summary_max_chars = max(
|
||||
CONTEXT_OVERFLOW_MESSAGE_CAP_FLOOR,
|
||||
settings.context_summary_max_chars // shrink_factor,
|
||||
)
|
||||
logger.info(
|
||||
"Provider rejected request as over context length "
|
||||
"(attempt %d/%d); shrinking oversized messages to %d "
|
||||
"chars and compacting to a %d-message tail before retrying",
|
||||
context_overflow_attempts,
|
||||
MAX_CONTEXT_OVERFLOW_RETRIES,
|
||||
message_cap,
|
||||
keep_tail,
|
||||
)
|
||||
shrink_large_messages(messages, message_cap)
|
||||
messages[:] = await compact_messages(
|
||||
llm, messages, keep_tail, summary_max_chars
|
||||
)
|
||||
continue
|
||||
logger.info("LLM error: %s", exc.message)
|
||||
return f"[model error] {exc.message}"
|
||||
|
||||
|
||||
@@ -175,8 +175,12 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
|
||||
is_admin=True,
|
||||
is_primary_admin=True,
|
||||
)
|
||||
tools = CATALOG.tool_schemas_for(
|
||||
client.authenticated, is_admin=True, is_primary_admin=True
|
||||
from .tool_prefs import filter_disabled
|
||||
|
||||
tools = filter_disabled(
|
||||
CATALOG.tool_schemas_for(
|
||||
client.authenticated, is_admin=True, is_primary_admin=True
|
||||
)
|
||||
)
|
||||
agentic.bind(
|
||||
llm=llm,
|
||||
|
||||
@@ -18,13 +18,14 @@ from devplacepy.config import (
|
||||
DEFAULT_AI_URL = INTERNAL_GATEWAY_URL
|
||||
DEFAULT_AI_MODEL = INTERNAL_MODEL
|
||||
DEFAULT_BASE_URL = f"http://127.0.0.1:{PORT}"
|
||||
CONTEXT_WINDOW_TOKENS = 1_048_576
|
||||
MAX_OUTPUT_TOKENS = 384_000
|
||||
SYSTEM_RESERVE_TOKENS = 64_000
|
||||
CONTEXT_WINDOW_TOKENS = 131_072
|
||||
MAX_OUTPUT_RESERVE_FRACTION = 0.20
|
||||
SYSTEM_RESERVE_FRACTION = 0.05
|
||||
CHARS_PER_TOKEN = 3
|
||||
CONTEXT_INPUT_BUDGET_TOKENS = (
|
||||
CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS
|
||||
CONTEXT_INPUT_BUDGET_TOKENS = int(
|
||||
CONTEXT_WINDOW_TOKENS * (1.0 - MAX_OUTPUT_RESERVE_FRACTION - SYSTEM_RESERVE_FRACTION)
|
||||
)
|
||||
SUMMARY_INPUT_FRACTION = 0.5
|
||||
|
||||
MIN_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_TIMEOUT_SECONDS = 300.0
|
||||
@@ -33,6 +34,9 @@ DEFAULT_MAX_TOOL_ITERATIONS = 40
|
||||
DEFAULT_DELEGATE_MAX_ITERATIONS = 25
|
||||
DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN
|
||||
DEFAULT_CONTEXT_KEEP_TAIL = 12
|
||||
DEFAULT_CONTEXT_SUMMARY_MAX_CHARS = int(
|
||||
CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN * SUMMARY_INPUT_FRACTION
|
||||
)
|
||||
DEFAULT_RECALL_TOP_K = 3
|
||||
DEFAULT_FETCH_MAX_CHARS = 200_000
|
||||
DEFAULT_FETCH_TIMEOUT_SECONDS = 300.0
|
||||
@@ -59,6 +63,7 @@ class Settings:
|
||||
delegate_max_iterations: int
|
||||
context_compact_threshold: int
|
||||
context_keep_tail: int
|
||||
context_summary_max_chars: int
|
||||
recall_top_k: int
|
||||
plan_required: bool
|
||||
verify_required: bool
|
||||
@@ -125,6 +130,11 @@ def load_settings() -> Settings:
|
||||
context_keep_tail=int(
|
||||
os.environ.get("DEVII_CONTEXT_KEEP_TAIL", DEFAULT_CONTEXT_KEEP_TAIL)
|
||||
),
|
||||
context_summary_max_chars=int(
|
||||
os.environ.get(
|
||||
"DEVII_CONTEXT_SUMMARY_MAX_CHARS", DEFAULT_CONTEXT_SUMMARY_MAX_CHARS
|
||||
)
|
||||
),
|
||||
recall_top_k=int(os.environ.get("DEVII_RECALL_TOP_K", DEFAULT_RECALL_TOP_K)),
|
||||
plan_required=os.environ.get("DEVII_PLAN_REQUIRED", "1").lower()
|
||||
not in ("0", "false", "no"),
|
||||
@@ -193,6 +203,7 @@ FIELD_TASK_MAX_PER_OWNER = "devii_task_max_per_owner"
|
||||
FIELD_TASK_DAILY_USD = "devii_task_daily_usd"
|
||||
FIELD_TASK_MAX_FAILURES = "devii_task_max_failures"
|
||||
FIELD_TASK_IDLE_DAYS = "devii_task_owner_idle_days"
|
||||
FIELD_DISABLED_TOOLS = "devii_disabled_tools"
|
||||
|
||||
DEFAULT_TASK_MAX_CONCURRENT = 4
|
||||
DEFAULT_TASK_DAILY_USD = 0.5
|
||||
@@ -237,6 +248,7 @@ def build_settings(
|
||||
delegate_max_iterations=DEFAULT_DELEGATE_MAX_ITERATIONS,
|
||||
context_compact_threshold=DEFAULT_CONTEXT_COMPACT_THRESHOLD,
|
||||
context_keep_tail=DEFAULT_CONTEXT_KEEP_TAIL,
|
||||
context_summary_max_chars=DEFAULT_CONTEXT_SUMMARY_MAX_CHARS,
|
||||
recall_top_k=DEFAULT_RECALL_TOP_K,
|
||||
plan_required=bool(config[FIELD_PLAN_REQUIRED]),
|
||||
verify_required=bool(config[FIELD_VERIFY_REQUIRED]),
|
||||
|
||||
@@ -36,6 +36,10 @@ class ToolInputError(DeviiError):
|
||||
code = "tool_input_error"
|
||||
|
||||
|
||||
class ToolDisabledError(DeviiError):
|
||||
code = "tool_disabled"
|
||||
|
||||
|
||||
class LLMError(DeviiError):
|
||||
code = "llm_error"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from .actions.ai_correction_actions import AI_CORRECTION_ACTIONS
|
||||
from .actions.ai_modifier_actions import AI_MODIFIER_ACTIONS
|
||||
from .actions.avatar_actions import AVATAR_ACTIONS
|
||||
from .actions.behavior_actions import BEHAVIOR_ACTIONS
|
||||
from .actions.catalog import ACTIONS
|
||||
from .actions.catalog import ACTIONS, CATALOG_GROUPS
|
||||
from .actions.chunk_actions import CHUNK_ACTIONS
|
||||
from .actions.client_actions import CLIENT_ACTIONS
|
||||
from .actions.container_actions import CONTAINER_ACTIONS
|
||||
@@ -17,7 +17,7 @@ from .actions.email_actions import EMAIL_ACTIONS
|
||||
from .actions.fetch_actions import FETCH_ACTIONS
|
||||
from .actions.notification_actions import NOTIFICATION_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .actions.spec import Action, Catalog
|
||||
from .actions.telegram_actions import TELEGRAM_ACTIONS
|
||||
from .actions.workspace_actions import WORKSPACE_ACTIONS
|
||||
from .interaction.actions import INTERACTION_ACTIONS
|
||||
@@ -25,6 +25,76 @@ from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
from .agentic.actions import AGENTIC_ACTIONS
|
||||
from .tasks.actions import TASK_ACTIONS
|
||||
|
||||
GROUPS: dict[str, tuple[Action, ...]] = {
|
||||
**CATALOG_GROUPS,
|
||||
"tasks": TASK_ACTIONS,
|
||||
"agentic": AGENTIC_ACTIONS,
|
||||
"avatar": AVATAR_ACTIONS,
|
||||
"client": CLIENT_ACTIONS,
|
||||
"fetch": FETCH_ACTIONS,
|
||||
"docs": DOCS_ACTIONS,
|
||||
"cost": COST_ACTIONS,
|
||||
"chunks": CHUNK_ACTIONS,
|
||||
"rsearch": RSEARCH_ACTIONS,
|
||||
"container": CONTAINER_ACTIONS,
|
||||
"workspace": WORKSPACE_ACTIONS,
|
||||
"customization": CUSTOMIZATION_ACTIONS,
|
||||
"behavior": BEHAVIOR_ACTIONS,
|
||||
"notification_tools": NOTIFICATION_ACTIONS,
|
||||
"ai_correction": AI_CORRECTION_ACTIONS,
|
||||
"ai_modifier": AI_MODIFIER_ACTIONS,
|
||||
"email": EMAIL_ACTIONS,
|
||||
"telegram": TELEGRAM_ACTIONS,
|
||||
"interaction": INTERACTION_ACTIONS,
|
||||
"virtual_tool": VIRTUAL_TOOL_ACTIONS,
|
||||
}
|
||||
|
||||
GROUP_LABELS: dict[str, str] = {
|
||||
"auth": "Authentication",
|
||||
"posts": "Posts",
|
||||
"comments": "Comments",
|
||||
"projects": "Projects",
|
||||
"project_files": "Project Files",
|
||||
"jobs": "Async Jobs (zip, fork, SEO, DeepSearch)",
|
||||
"tools": "Diagnostic Tools",
|
||||
"profile": "Profile",
|
||||
"messages": "Direct Messages",
|
||||
"notifications": "Notifications (HTTP)",
|
||||
"engagement": "Reactions, Bookmarks, Polls, Follow",
|
||||
"social": "Leaderboard and Social",
|
||||
"issues": "Issue Tracker",
|
||||
"gists": "Gists",
|
||||
"news": "Developer News",
|
||||
"uploads": "Uploads and Media",
|
||||
"admin": "Admin Management",
|
||||
"dbapi": "Database (primary admin only)",
|
||||
"gateway": "AI Gateway Admin",
|
||||
"game": "Code Farm",
|
||||
"quizzes": "Quizzes",
|
||||
"battles": "Opinion Wars",
|
||||
"moderation": "Moderation",
|
||||
"tasks": "Scheduled Tasks and Reminders",
|
||||
"agentic": "Self-Evaluation (delegate, eval)",
|
||||
"avatar": "Browser Avatar Tutorials",
|
||||
"client": "Browser Automation",
|
||||
"fetch": "Web Fetch and HTTP",
|
||||
"docs": "Docs Search",
|
||||
"cost": "Usage and Cost",
|
||||
"chunks": "Large Result Paging",
|
||||
"rsearch": "External Web Search",
|
||||
"container": "Containers",
|
||||
"workspace": "Workspaces",
|
||||
"customization": "Per-User CSS/JS",
|
||||
"behavior": "Self-Configured Behavior",
|
||||
"notification_tools": "Notifications (agent tool)",
|
||||
"ai_correction": "AI Content Correction",
|
||||
"ai_modifier": "AI Modifier (@ai)",
|
||||
"email": "Email (IMAP/SMTP)",
|
||||
"telegram": "Telegram Bridge",
|
||||
"interaction": "Interactive Widgets (UI prompts)",
|
||||
"virtual_tool": "User-Defined Tools",
|
||||
}
|
||||
|
||||
CATALOG = Catalog(
|
||||
actions=ACTIONS
|
||||
+ TASK_ACTIONS
|
||||
@@ -69,4 +139,27 @@ def _assert_confirm_params(catalog: Catalog) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _assert_group_coverage(catalog: Catalog, groups: dict[str, tuple[Action, ...]]) -> None:
|
||||
grouped_names = {action.name for group in groups.values() for action in group}
|
||||
catalog_names = set(catalog.by_name())
|
||||
missing = sorted(catalog_names - grouped_names)
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"Devii tools registered in CATALOG but missing from GROUPS, so the admin tool-catalog "
|
||||
"UI can never show or disable them: " + ", ".join(missing) + ". Add the action's tuple "
|
||||
"to registry.py GROUPS (and a label in GROUP_LABELS)."
|
||||
)
|
||||
|
||||
|
||||
def _assert_group_labels(groups: dict[str, tuple[Action, ...]], labels: dict[str, str]) -> None:
|
||||
missing = sorted(set(groups) - set(labels))
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"Devii tool groups missing a GROUP_LABELS entry, so the admin tool-catalog UI would "
|
||||
"show a raw key instead of a name: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
_assert_confirm_params(CATALOG)
|
||||
_assert_group_coverage(CATALOG, GROUPS)
|
||||
_assert_group_labels(GROUPS, GROUP_LABELS)
|
||||
|
||||
@@ -507,8 +507,12 @@ class DeviiSession:
|
||||
track_action(self.owner_id, "devii")
|
||||
|
||||
def _builtin_tools(self) -> list[dict[str, Any]]:
|
||||
schemas = CATALOG.tool_schemas_for(
|
||||
self.client.authenticated, self.is_admin, self.is_primary_admin
|
||||
from ..tool_prefs import filter_disabled
|
||||
|
||||
schemas = filter_disabled(
|
||||
CATALOG.tool_schemas_for(
|
||||
self.client.authenticated, self.is_admin, self.is_primary_admin
|
||||
)
|
||||
)
|
||||
if self.channel == "docs":
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
|
||||
from .config import FIELD_DISABLED_TOOLS
|
||||
from .registry import GROUPS, GROUP_LABELS
|
||||
|
||||
logger = logging.getLogger("devii.tool_prefs")
|
||||
|
||||
GROUPS_BY_TOOL_NAME: dict[str, str] = {
|
||||
action.name: group_key for group_key, actions in GROUPS.items() for action in actions
|
||||
}
|
||||
|
||||
PROTOCOL_TOOL_NAMES = frozenset({"plan", "verify"})
|
||||
|
||||
|
||||
def disabled_tool_names() -> frozenset[str]:
|
||||
raw = get_setting(FIELD_DISABLED_TOOLS, "")
|
||||
if not raw:
|
||||
return frozenset()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Could not parse %s, treating as empty", FIELD_DISABLED_TOOLS)
|
||||
return frozenset()
|
||||
if not isinstance(data, list):
|
||||
return frozenset()
|
||||
return frozenset(str(name) for name in data) - PROTOCOL_TOOL_NAMES
|
||||
|
||||
|
||||
def set_disabled_tool_names(names: set[str] | frozenset[str]) -> None:
|
||||
known = (set(GROUPS_BY_TOOL_NAME) & set(names)) - PROTOCOL_TOOL_NAMES
|
||||
set_setting(FIELD_DISABLED_TOOLS, json.dumps(sorted(known)))
|
||||
|
||||
|
||||
def filter_disabled(
|
||||
schemas: list[dict[str, Any]], disabled: frozenset[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
if disabled is None:
|
||||
disabled = disabled_tool_names()
|
||||
if not disabled:
|
||||
return schemas
|
||||
return [
|
||||
schema
|
||||
for schema in schemas
|
||||
if schema.get("function", {}).get("name") not in disabled
|
||||
]
|
||||
|
||||
|
||||
def group_overview() -> list[dict[str, Any]]:
|
||||
disabled = disabled_tool_names()
|
||||
overview = []
|
||||
for key in sorted(GROUPS, key=lambda k: GROUP_LABELS[k].lower()):
|
||||
actions = GROUPS[key]
|
||||
tools = [
|
||||
{
|
||||
"name": action.name,
|
||||
"summary": action.summary,
|
||||
"requires_admin": action.requires_admin,
|
||||
"requires_primary_admin": action.requires_primary_admin,
|
||||
"disabled": action.name in disabled,
|
||||
"protocol": action.name in PROTOCOL_TOOL_NAMES,
|
||||
}
|
||||
for action in sorted(actions, key=lambda a: a.name)
|
||||
]
|
||||
overview.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": GROUP_LABELS[key],
|
||||
"tools": tools,
|
||||
"enabled_count": sum(1 for t in tools if not t["disabled"]),
|
||||
"total_count": len(tools),
|
||||
}
|
||||
)
|
||||
return overview
|
||||
@@ -117,7 +117,9 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
|
||||
|
||||
**The two AI quotas are separate systems and the reset surfaces must say so.** `/admin/ai-usage`'s *Reset all quotas* clears the Devii `devii_usage_ledger` AND now also stamps a global gateway watermark, because a caller hitting `429 AI gateway daily quota exceeded` had no reset at all before and the button looked global. *Reset guest quotas* stays Devii-only (guest gateway calls ride the shared internal key, so there is no per-guest gateway scope to clear).
|
||||
|
||||
**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. Reset is `POST /admin/gateway/quota-resets` (same file, `_payload`/`ValidationError` shape as the rule CRUD), audited `gateway.quota.reset` (category `ai`), surfaced as a per-rule **Reset spend** button in the Quota rules table (`GatewayAdmin.js`), and exposed as the Devii tool `gateway_quota_reset` (`requires_admin=True`, in `CONFIRM_REQUIRED` with a declared `confirm` param, like the other quota-lifting admin resets). CLI: `devplace gateway quota list|set|delete|reset`.
|
||||
**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`). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same JSON endpoints via `handler="http"`, same as the provider/model tools. Reset is `POST /admin/gateway/quota-resets` (same file, `_payload`/`ValidationError` shape as the rule CRUD), audited `gateway.quota.reset` (category `ai`), and exposed as the Devii tool `gateway_quota_reset` (`requires_admin=True`, in `CONFIRM_REQUIRED` with a declared `confirm` param, like the other quota-lifting admin resets). CLI: `devplace gateway quota list|set|delete|reset`.
|
||||
|
||||
The human-facing **Quota rules** tab of `/admin/gateway` is a third, independent backend-rendered surface over the same `quota_rule_store` (see "Admin UI (backend-rendered, no JS)" above): `GET/POST /admin/gateway/quota-rules/new`, `GET/POST /admin/gateway/quota-rules/{uid}/edit`, `POST /admin/gateway/quota-rules/{uid}/delete`, and a per-row `POST /admin/gateway/quota-rules/{uid}/reset` button (`templates/admin_gateway_quota_form.html` for add/edit; the reset/delete actions are plain `<form>` buttons with `data-confirm` on the index page, no fetch call). These do not replace the JSON endpoints above - Devii and the CLI still talk to those directly.
|
||||
|
||||
## Image generation
|
||||
|
||||
@@ -152,21 +154,44 @@ Layered ON TOP of the single-provider service config above, which stays THE impl
|
||||
|
||||
**No matching route = None overlay = model fallback.** When no route matches, the overlay returns `None` and the handler's model selection logic falls back to the default configured model (`gateway_model` for chat, `gateway_embed_model` for embeddings, `gateway_image_model` for images). The unknown model name is discarded, not forwarded upstream. This means an unknown or misspelled model name never causes a 4xx from the upstream - it is gracefully downgraded to the default. The `force_model` guard and the built-in `molodetz`/`molodetz~embed`/`molodetz-img-small` aliases are still respected before the route check: `force_model` or empty/named-alias model -> default directly; known model -> route resolution; unknown model with no route -> default fallback with a log message.
|
||||
|
||||
**CRUD.** Admin JSON at `/admin/gateway/{providers,models}` (`routers/admin/gateway_configs.py`, `require_admin`, Pydantic `ProviderIn`/`ModelRouteIn` validation, accepts both JSON from `static/js/GatewayAdmin.js` and form from Devii), audited under `gateway.provider.*`/`gateway.model.*` (category `ai`), included in the admin package with the page at `/admin/gateway` (`templates/admin_gateway.html`, sidebar link, `admin_section="gateway"`).
|
||||
**CRUD.** Admin JSON at `/admin/gateway/{providers,models}` (`routers/admin/gateway_configs.py`, `require_admin`, Pydantic `ProviderIn`/`ModelRouteIn` validation via the shared `_payload()` JSON-or-form reader, `_save_provider`/`_save_model`/`_check_fallback` helpers), audited under `gateway.provider.*`/`gateway.model.*` (category `ai`). This JSON surface is for Devii and programmatic callers only - see "Admin UI (backend-rendered, no JS)" below for the human-facing page, which is a completely separate set of routes over the same stores.
|
||||
|
||||
**Devii tools:** `gateway_providers`/`gateway_models` (read) and `gateway_provider_set`/`gateway_provider_delete`/`gateway_model_set`/`gateway_model_delete` (admin; the two deletes are `CONFIRM_REQUIRED`).
|
||||
|
||||
## Admin UI (backend-rendered, no JS)
|
||||
|
||||
`/admin/gateway` (`templates/admin_gateway.html`, sidebar link, `admin_section="gateway"`) is a plain server-rendered page - no `GatewayAdmin.js` (deleted), no page controller driving CRUD. It is a three-tab index (`?tab=models|providers|quota`, plain `<a>` links, `.admin-tabs`/`.admin-tab`) listing each resource in an `.admin-table`; every add/edit is its **own dedicated page**, never an inline form under the table:
|
||||
|
||||
- Providers: `GET/POST /admin/gateway/providers/new`, `GET/POST /admin/gateway/providers/{name}/edit`, `POST /admin/gateway/providers/{name}/delete` (`templates/admin_gateway_provider_form.html`). `name` is the primary key and is rendered `readonly` on the edit page - renaming would silently orphan the old row in `gateway_providers` rather than updating it (`ProviderStore.set` keys on `find_one(name=...)`), so renaming is not offered; delete and re-add instead.
|
||||
- Model routes: `GET/POST /admin/gateway/models/new`, `GET/POST /admin/gateway/models/{source_model:path}/edit` (path converter, since a source model name is not guaranteed to be slash-free), `POST /admin/gateway/models/{source_model:path}/delete` (`templates/admin_gateway_model_form.html`). `source_model` is likewise `readonly` on edit for the same orphan-row reason. Every field is always rendered (chat pricing, embed/vision pricing, tiered pricing, off-peak window) grouped into `<fieldset>`s by applicability rather than JS-toggled by the selected `kind` - the hint text under each fieldset states which kind it applies to. The fallback-model `<select>` is grouped into one `<optgroup>` per kind (pure HTML, no live filtering); the server still authoritatively rejects a cross-kind fallback via `_check_fallback`. The **target model** field is the one genuinely dynamic control on this page - see "Live target-model lookup" below.
|
||||
- Quota rules: `GET/POST /admin/gateway/quota-rules/new`, `GET/POST /admin/gateway/quota-rules/{uid}/edit`, `POST /admin/gateway/quota-rules/{uid}/delete`, `POST /admin/gateway/quota-rules/{uid}/reset` (`templates/admin_gateway_quota_form.html`). Unlike providers/models, a quota rule's `uid` is never user-editable, so there is no orphan-row risk and no field needs to be readonly.
|
||||
|
||||
### Live target-model lookup (the one dynamic field on this page)
|
||||
|
||||
The model-route form's **target model** field is a deliberate, narrow exception to "backend-rendered, no JS": picking a provider can turn it from a free-text input into a `<select>` populated with that provider's own real model list, but the browser never talks to the provider directly.
|
||||
|
||||
- **Server does the URL resolution and the outbound call.** `routing._models_url_from_base(base_url)` derives the OpenAI-style listing endpoint from a provider's chat-completions `base_url` (swaps `/chat/completions` -> `/models`, exactly like the existing `_embed_url_from_base`/`_image_url_from_base` derivations), and `routing.fetch_provider_models(provider_name)` (async) resolves the named provider's (or, for a blank name, the **default** provider's - `_default_provider_credentials()` reads `gateway_upstream_url`/`gateway_api_key` off the service config the same way `_default_provider_summary()` does) `base_url`/`api_key`, calls the derived URL through `stealth.stealth_async_client` (never a bare `httpx.AsyncClient` - see the root `CLAUDE.md` outbound-HTTP rule) with an `authorization: Bearer <api_key>` header, and parses the OpenAI `{"data": [{"id": ...}, ...]}` shape into a plain list of id strings. It returns `None` - never raises - for every failure mode: unknown provider, blank/unresolvable base URL, a non-200 response, an unparseable body, an empty list, or any network exception (timeout, connection refused, DNS failure). `None` is the single "can't do this" signal the caller (and the frontend) all key off.
|
||||
- **`GET /admin/gateway/provider-models?provider=<name-or-blank>`** (`routers/admin/gateway_configs.py::provider_models`, admin-gated, JSON-only, no page) is the one endpoint the browser is allowed to call: `require_admin` then `fetch_provider_models(provider)`, returning `{"provider", "models": [...]}` on success or a `404 {"error": {...}}` (via `json_error`) when the result was `None`. This is the ONLY thing `static/js/GatewayModelForm.js` fetches - it never reaches a third-party host, satisfying "we do not let the JavaScript make a connection to a remote site."
|
||||
- **`static/js/GatewayModelForm.js`** (loaded only by `admin_gateway_model_form.html`'s `{% block extra_js %}`, not global) is the one page-specific controller left in this subsystem. The template always renders BOTH `#gw-model-target` (a `required` text input, the no-JS/JS-still-loading baseline and the true fallback) and `#gw-model-target-select` (a `required` but initially `hidden disabled` `<select>` with the same `name="target_model"`) - exactly one of the pair is ever `disabled` at a time (a `disabled` field is excluded from form submission, so there is never a duplicate `target_model` value), and the controller toggles `.hidden`/`.disabled` on both together. On the provider `<select>`'s `change` event, and once on page load ONLY when a provider is already selected (an edit page for a named-provider route - a brand-new form or a "default"/blank provider selection never auto-probes on load, since that would fire a real outbound network probe against the default gateway just from opening the page), it calls `Http.getJson("/admin/gateway/provider-models?provider=...")`; a 200 swaps to the select (populated from the response, the field's current value prepended as an extra option if the provider's own list does not contain it, so an existing customized/legacy target is never silently discarded) and a thrown error (404 or any other failure) swaps back to the free-text input. The `<select>`'s own HTML `required` attribute is what makes "not allowed to be empty" hold even with JS disabled-then-re-enabled - no JS-side validation was added.
|
||||
- **Tests:** `tests/unit/services/openai_gateway/routing.py` (`_models_url_from_base`, `fetch_provider_models` success/unknown-provider/non-200/malformed-payload/empty-list/network-error/no-base-url, all via a fake `stealth_async_client`), `tests/api/admin/gateway/provider_models.py` (the endpoint over a real local `http.server` fake upstream, mirroring the pattern already used by `tests/api/containers.py`'s ingress test), and `tests/e2e/admin/gateway.py` (a real browser swapping the field between a listing-capable and a blind provider, and a full submit through the dynamically-populated select).
|
||||
|
||||
**Validation errors re-render the same form, not a silent redirect.** Each POST handler builds the pydantic model itself (`ModelRouteIn`/`ProviderIn`/`QuotaRuleIn`) rather than going through the generic `Depends(json_or_form(...))` + `RequestValidationError` path used by simpler admin forms (e.g. `/admin/settings`) - that generic handler redirects non-auth pages back to the referer with the message dropped, which would silently swallow a genuine validation failure (a bad URL, a cross-kind fallback) on a page whose whole point is surfacing exactly that feedback. `_error_response()` re-renders the form template with a `.gw-error` banner and every submitted value preserved, at `400`.
|
||||
|
||||
**Time-of-day fields are converted at the router, not in the browser.** `off_peak_start`/`off_peak_end` are plain `<input type="time">` fields (`HH:MM` strings); `_time_to_minutes`/`_minutes_to_time` in `gateway_configs.py` convert to/from the stored `off_peak_start_minute`/`off_peak_end_minute` integers - this used to be a client-side JS computation (`GatewayAdmin.js` `timeToMinutes`/`minutesToTime`), now server-side only.
|
||||
|
||||
**The JSON API above is untouched and still the only surface Devii/`devplace gateway quota` talk to** - the backend-rendered pages are a second, independent set of routes over the same `provider_store`/`model_store`/`quota_rule_store`, sharing the save/delete/fallback-check logic via `_save_provider`/`_save_model`/`_check_fallback`/`_save_quota_rule` helper functions so the two surfaces can never drift on what counts as valid.
|
||||
|
||||
When adding a routed value, overlay it as the matching `gateway_*` cfg key so the runtime needs no new branch.
|
||||
|
||||
**Tests:** `tests/unit/services/openai_gateway/routing.py` (overlay/economy/kind isolation), `tests/unit/services/openai_gateway/gateway.py::test_model_route_overrides_upstream` (end-to-end through `handle_chat`), and `tests/api/admin/gateway/` (admin CRUD + validation + role gating).
|
||||
|
||||
## Automatic model fallback (`fallback_model`, one hop, per route)
|
||||
|
||||
Any `gateway_models` route (chat, embed, or image) can optionally name `fallback_model`: another already-configured `source_model` of the SAME kind, tried once, automatically, when the primary route fails. This is admin-configured on the `/admin/gateway` model form as a select box populated from every OTHER route of the current kind (the public `source_model` names such as `molodetz`/`molodetz-pro`, never the internal `target_model` a provider actually sees) - never a free-text field, so a fallback can only ever point at a model the gateway already knows how to serve.
|
||||
Any `gateway_models` route (chat, embed, or image) can optionally name `fallback_model`: another already-configured `source_model` of the SAME kind, tried once, automatically, when the primary route fails. This is admin-configured on the `/admin/gateway` model form as a select box grouped into one `<optgroup>` per kind over every OTHER route (the public `source_model` names such as `molodetz`/`molodetz-pro`, never the internal `target_model` a provider actually sees) - never a free-text field, so a fallback can only ever point at a model the gateway already knows how to serve.
|
||||
|
||||
- **Trigger.** `_call_failed(resp, exc, timing)` in `gateway.py` treats a call as failed when the circuit breaker rejected it, the upstream connection raised, or the upstream answered with any status `>= 400`. This runs AFTER `_send`'s own `gateway_max_retries` retries against the primary model are exhausted - a fallback is the next escalation once retrying the SAME model has already given up, not a replacement for that retry loop.
|
||||
- **One hop, never a chain.** `routing.resolve_fallback(source_model, kind)` resolves the primary route, reads its `fallback_model`, and resolves THAT model's own route (must exist, be active, and share the kind) - it does NOT recurse into the fallback's own `fallback_model`, so there is no possibility of a cycle or an unbounded retry chain. A route that fails even after redirecting to its fallback returns the fallback attempt's own failure to the caller.
|
||||
- **Self-reference is rejected at write time.** `ModelRouteIn._check_fallback_is_not_self` (pydantic `model_validator`) refuses `fallback_model == source_model`; the admin route handler (`routers/admin/gateway_configs.py::save_model`) additionally rejects a `fallback_model` that does not resolve to an existing, same-kind route (`routing.model_store.get(...)`) before writing the row, since a pydantic validator alone cannot see other rows.
|
||||
- **Self-reference is rejected at write time.** `ModelRouteIn._check_fallback_is_not_self` (pydantic `model_validator`) refuses `fallback_model == source_model`; `routers/admin/gateway_configs.py::_check_fallback` (shared by the JSON `save_model_json` route and both backend-rendered model-route pages) additionally rejects a `fallback_model` that does not resolve to an existing, same-kind route (`routing.model_store.get(...)`) before writing the row, since a pydantic validator alone cannot see other rows.
|
||||
- **Rebuilt like a fresh routed call, not retried in place.** On failure, `handle_chat`/`handle_embeddings`/`handle_images` recompute the overlay from `base_cfg` (the pre-primary-overlay config) via `chat_overlay(fallback_route.source_model, base_cfg)`/`embed_overlay`/`image_overlay`, so the fallback gets its OWN provider, URL, key, and pricing - never the primary route's. `pricing`/`context_map` for the ledger are (re)computed AFTER the fallback decision so the recorded cost always reflects whichever model actually served the request.
|
||||
- **One ledger row per client call, not one per attempt.** The primary failure never writes a `gateway_usage_ledger` row by itself (only `_send`'s own internal retries and the circuit breaker counters observe it); `finalize()`/`self._ledger.record(...)` still runs exactly once, after the fallback decision, with `model` = whichever model ultimately answered and `requested_model` = the ORIGINALLY requested model name (an existing-but-previously-unpopulated ledger column, now populated by all three handlers) - so a fallback shows up in `/admin/ai-usage` as "requested X, served Y" rather than as two calls.
|
||||
- **Streaming falls back before any byte reaches the client.** `client.send(request, stream=True)` returns as soon as the response headers arrive, so `resp.status_code` is already known before the SSE body is ever touched - `handle_chat` decides whether to fall back at that same point, before it ever opens `_stream_chat_response` to the caller. A stream is therefore never abandoned mid-flight in favor of a fallback; the caller either gets the fallback's own stream from byte one, or the fallback's own definitive failure.
|
||||
@@ -182,7 +207,7 @@ Real providers sometimes charge more than a flat per-1M rate for one component:
|
||||
- **Per-route fields** (each optional/zero by default = feature off, so an unconfigured route is byte-identical to before): `context_tier_threshold_tokens` (0 disables tiering; when the request's input token count exceeds it, `price_*_per_m_tier2` rates apply instead of the tier-1 rates above) and the four nullable `price_cache_hit_per_m_tier2`/`price_cache_miss_per_m_tier2`/`price_output_per_m_tier2`/`price_input_per_m_tier2` (a component left `None` keeps its tier-1 rate even above threshold - so a provider that only re-prices input above a size threshold, keeping output flat, needs just one tier2 field set), plus `off_peak_start_minute`/`off_peak_end_minute` (nullable, UTC minutes-since-midnight, both-or-neither enforced by `ModelRouteIn`'s model validator; a window where start > end wraps past midnight, e.g. DeepSeek's old V3/R1-era 16:30-00:30 UTC window) and `off_peak_discount_pct` (0-100, multiplies whichever tier rate is active). The same fields double for vision (`price_input/output_per_m_tier2`) and embed (`price_input_per_m_tier2`) exactly like their tier-1 counterparts already do.
|
||||
- **Overlay + computation.** `chat_overlay`/`embed_overlay` propagate all of these into the per-request cfg dict under `gateway_*` keys exactly like the existing price fields (section above); `usage.pricing_from_cfg` reads them generically (defaulting to `None`/`0`/disabled when absent), so **Layer A (the single global flat Pricing config fields) never gains these dimensions** - only a `gateway_models` route can enable them, preserving the "no matching route = byte-identical legacy behavior" guarantee. `usage.compute_cost` selects tier1 vs tier2 per rate component (`_tiered_rate`) based on whether `norm["prompt"]` (billable input tokens) exceeds the threshold, then applies the off-peak discount (`_effective_rate`/`_off_peak_active`, UTC wraparound-aware) to whichever rate was selected. **The response header format and `compute_cost`'s return shape (`total, input_cost, output_cost, native`) are unchanged** - this is purely an internal rate-selection step before the existing input/output split math runs; a native upstream `cost` (OpenRouter) still overrides the modeled total exactly as before.
|
||||
- **Migration.** New `gateway_models` columns are added via `has_column`/`create_column_by_example` in `routing.ensure_tables()` (the `CREATE TABLE IF NOT EXISTS` DDL string alone would never reach a pre-existing table - see the `database/CLAUDE.md` column-ensure idiom).
|
||||
- **Admin UI.** `/admin/gateway`'s model-route form has a "Tiered / off-peak pricing (optional)" subsection; off-peak start/end render as `<input type="time">` (converted to/from UTC minutes-of-day by `GatewayAdmin.js`), and the routes table shows `tiered`/`off-peak` badges when a route has either dimension configured.
|
||||
- **Admin UI.** `/admin/gateway`'s model-route add/edit page has a "Tiered pricing" and an "Off-peak discount" fieldset; off-peak start/end render as `<input type="time">` (converted to/from UTC minutes-of-day server-side by `_time_to_minutes`/`_minutes_to_time` in `routers/admin/gateway_configs.py`), and the models tab table shows `tiered`/`off-peak` badges when a route has either dimension configured.
|
||||
- **DeepSeek's real pricing is already the tier-1 shape, not a new dimension.** DeepSeek's actual API (verified against `api-docs.deepseek.com/quick_start/pricing`) bills three flat per-1M rates - cache-hit input, cache-miss input, output - with no current context-length tier or off-peak window for the V4 models; that shape was already fully modeled by the pre-existing `chat_cache_hit_per_m`/`chat_cache_miss_per_m`/`chat_output_per_m` fields before this section's tier2/off-peak fields existed. `routing.seed_default_deepseek_routes()` (called once from `database.migrate_ai_gateway_settings()` at the end of `init_db()`) idempotently inserts four ready-made routes - `deepseek-v4-flash` (`$0.0028`/`$0.14`/`$0.28` per 1M, 1M context), `deepseek-v4-pro` (`$0.003625`/`$0.435`/`$0.87` per 1M, 1M context), and the two public `molodetz` aliases `molodetz` -> `deepseek-v4-flash` (flash rates) and `molodetz-pro` -> `deepseek-v4-pro` (pro rates) - only when that `source_model` row does not already exist, so a caller or Devii can request any name explicitly and get correctly-priced, decoupled from whatever the single global `gateway_model` default happens to be set to (switching that global setting between the two real models does NOT retroactively fix the flat Pricing config fields - the seeded routes are the model-agnostic, always-correct way to reference a specific priced model). The `molodetz`/`molodetz-pro` aliases are the public model names; `GET /v1/models` is served locally from these `source_model` rows (not proxied upstream), so it publishes exactly the models the gateway accepts. Neither seeded route sets the tier2/off-peak fields (DeepSeek does not use them today); an admin can add them later on the same row if DeepSeek (or any other provider routed here) introduces such pricing.
|
||||
|
||||
## Third-party AI consent gate (one place, two owner classes)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Optional
|
||||
import httpx
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
|
||||
from devplacepy import config as devplace_config
|
||||
from devplacepy import stealth
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.openai_gateway import config
|
||||
@@ -27,6 +28,7 @@ from devplacepy.services.openai_gateway.thinking import (
|
||||
from devplacepy.services.openai_gateway.usage import (
|
||||
GatewayUsageLedger,
|
||||
classify_error,
|
||||
embedded_error_message,
|
||||
extract_image_usage,
|
||||
extract_params,
|
||||
parse_context_map,
|
||||
@@ -45,6 +47,50 @@ def _call_failed(resp, exc, timing: dict) -> bool:
|
||||
return resp is not None and resp.status_code >= 400
|
||||
|
||||
|
||||
# A malformed/unsupported request (bad JSON schema, an unsupported param)
|
||||
# fails identically against a fallback model - retrying it there only doubles
|
||||
# latency and upstream call count for a guaranteed repeat failure.
|
||||
# context_length is deliberately NOT in this set: a fallback model commonly
|
||||
# has a different (often larger) context window than the primary, so it can
|
||||
# genuinely recover a request the primary couldn't fit - see
|
||||
# _fits_context_window, which additionally skips a doomed primary attempt
|
||||
# outright when its own declared window is already known to be too small.
|
||||
# Every other failure class (timeout, connection error, circuit-open, auth,
|
||||
# rate limit, 5xx, an embedded 200-with-error body) still falls back too,
|
||||
# since a different provider/model plausibly can recover.
|
||||
_NO_FALLBACK_CATEGORIES = {"bad_request"}
|
||||
|
||||
|
||||
def _should_fallback(resp, exc, timing: dict, body_error: Optional[str] = None) -> bool:
|
||||
if not (_call_failed(resp, exc, timing) or body_error):
|
||||
return False
|
||||
if exc is not None or timing.get("circuit_open") or body_error:
|
||||
return True
|
||||
if resp is not None:
|
||||
try:
|
||||
message = resp.text
|
||||
except Exception: # noqa: BLE001 - a decode failure is not a reason to skip fallback
|
||||
message = ""
|
||||
if classify_error(resp.status_code, None, message) in _NO_FALLBACK_CATEGORIES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _embedded_failure_message(resp, stream: bool) -> Optional[str]:
|
||||
# A 200 with the failure embedded in the JSON body (OpenRouter and the
|
||||
# providers it fronts do this - see usage.embedded_error_message) is
|
||||
# invisible to _call_failed's status-code check. Only safe to peek for a
|
||||
# non-streaming response: a streaming 200's body has not been read yet
|
||||
# here and must stay unread for real incremental relay.
|
||||
if stream or resp is None or resp.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
data = resp.json()
|
||||
except (ValueError, httpx.HTTPError):
|
||||
return None
|
||||
return embedded_error_message(data)
|
||||
|
||||
|
||||
def _apply_served_model(headers: dict, resp) -> dict:
|
||||
# Everything else in `headers` (cost, tokens, latency, context) is our
|
||||
# own measurement and must never be overwritten by an upstream that
|
||||
@@ -58,13 +104,58 @@ def _apply_served_model(headers: dict, resp) -> dict:
|
||||
return headers
|
||||
|
||||
|
||||
def _attribution_headers() -> dict:
|
||||
# OpenRouter's optional HTTP-Referer/X-Title (openrouter.ai/docs/api-reference/overview)
|
||||
# attribute calls to DevPlace on their rankings; harmless extra headers for
|
||||
# any other upstream, so sent unconditionally rather than dialect-gated.
|
||||
headers: dict = {"X-Title": "DevPlace"}
|
||||
if devplace_config.SITE_URL:
|
||||
headers["HTTP-Referer"] = devplace_config.SITE_URL
|
||||
return headers
|
||||
|
||||
|
||||
def _extra_provider_headers(cfg: dict) -> dict:
|
||||
# Set by chat_overlay/embed_overlay/image_overlay for a provider that
|
||||
# needs special request headers (e.g. OpenCode Zen's client-identity
|
||||
# spoofing) - a no-op dict for every provider that doesn't.
|
||||
return cfg.get("gateway_extra_request_headers") or {}
|
||||
|
||||
|
||||
def _fallback_headers(cfg: dict, key_field: str) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**_attribution_headers(),
|
||||
**_extra_provider_headers(cfg),
|
||||
}
|
||||
if cfg.get(key_field):
|
||||
headers["Authorization"] = f"Bearer {cfg[key_field]}"
|
||||
return headers
|
||||
|
||||
|
||||
REQUEST_TOKEN_ESTIMATE_CHARS_PER_TOKEN = 4
|
||||
REQUEST_TOKEN_ESTIMATE_DEFAULT_MAX_TOKENS = 512
|
||||
REQUEST_TOKEN_ESTIMATE_SAFETY_MARGIN = 256
|
||||
|
||||
|
||||
def _estimate_request_tokens(payload: dict) -> int:
|
||||
# A rough chars/4 heuristic, not a real tokenizer - deliberately
|
||||
# conservative (rounds up via the safety margin) since it only ever
|
||||
# decides whether to skip a doomed attempt in favor of the fallback,
|
||||
# never whether to dispatch at all.
|
||||
body_tokens = len(json.dumps(payload)) // REQUEST_TOKEN_ESTIMATE_CHARS_PER_TOKEN
|
||||
max_tokens = (
|
||||
payload.get("max_tokens")
|
||||
or payload.get("max_completion_tokens")
|
||||
or REQUEST_TOKEN_ESTIMATE_DEFAULT_MAX_TOKENS
|
||||
)
|
||||
return body_tokens + int(max_tokens) + REQUEST_TOKEN_ESTIMATE_SAFETY_MARGIN
|
||||
|
||||
|
||||
def _route_context_window(cfg: dict, model: str) -> Optional[int]:
|
||||
window = parse_context_map(cfg.get("gateway_model_context_map")).get(model)
|
||||
return int(window) if window else None
|
||||
|
||||
|
||||
def _notify_gateway_status(is_open: bool) -> None:
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import create_notification
|
||||
@@ -106,6 +197,14 @@ class GatewayRuntime:
|
||||
self._breaker = CircuitBreaker(
|
||||
config.CIRCUIT_THRESHOLD_DEFAULT, config.CIRCUIT_COOLDOWN_SECONDS_DEFAULT
|
||||
)
|
||||
# Vision calls default to a different upstream (OpenRouter) than chat
|
||||
# (DeepSeek) and are not routed through _send, so they get their own
|
||||
# breaker instance - sharing self._breaker would let a struggling
|
||||
# vision provider suppress unrelated chat traffic to a healthy
|
||||
# provider, which is a new coupling this change must not introduce.
|
||||
self._vision_breaker = CircuitBreaker(
|
||||
config.CIRCUIT_THRESHOLD_DEFAULT, config.CIRCUIT_COOLDOWN_SECONDS_DEFAULT
|
||||
)
|
||||
self.requests = 0
|
||||
self.errors = 0
|
||||
self.in_flight = 0
|
||||
@@ -128,8 +227,17 @@ class GatewayRuntime:
|
||||
limits = httpx.Limits(
|
||||
max_connections=instances, max_keepalive_connections=instances
|
||||
)
|
||||
# A single blanket timeout makes a dead-on-arrival upstream (DNS
|
||||
# blackhole, firewalled port, refused connection behind a broken
|
||||
# LB) take the FULL configured timeout - minutes, since
|
||||
# gateway_timeout floors at 5 minutes - just to fail the connect
|
||||
# phase, once per retry attempt. Connect should fail fast; only
|
||||
# read/write/pool (waiting on a slow-but-alive generation) need
|
||||
# the long budget.
|
||||
connect_timeout = min(10.0, float(timeout))
|
||||
self._client = stealth.stealth_async_client(
|
||||
timeout=float(timeout), limits=limits
|
||||
timeout=httpx.Timeout(float(timeout), connect=connect_timeout),
|
||||
limits=limits,
|
||||
)
|
||||
self._sem = asyncio.Semaphore(instances)
|
||||
self._instances = instances
|
||||
@@ -143,6 +251,9 @@ class GatewayRuntime:
|
||||
self._breaker.configure(
|
||||
cfg["gateway_circuit_threshold"], cfg["gateway_circuit_cooldown_seconds"]
|
||||
)
|
||||
self._vision_breaker.configure(
|
||||
cfg["gateway_circuit_threshold"], cfg["gateway_circuit_cooldown_seconds"]
|
||||
)
|
||||
return self._client, self._sem
|
||||
|
||||
async def aclose(self) -> None:
|
||||
@@ -219,7 +330,12 @@ class GatewayRuntime:
|
||||
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:
|
||||
if resp.status_code >= 500 or resp.status_code == 429:
|
||||
# A 429 that survived retry_send's own retries means the upstream
|
||||
# is still rate-limiting us after gateway_max_retries attempts -
|
||||
# score it like a 5xx so sustained rate-limit exhaustion opens the
|
||||
# breaker instead of paying the full retry/backoff cost on every
|
||||
# single request indefinitely.
|
||||
self.errors += 1
|
||||
was_open = self._breaker.is_open
|
||||
self._breaker.record_failure()
|
||||
@@ -251,6 +367,7 @@ class GatewayRuntime:
|
||||
|
||||
vision_cost = 0.0
|
||||
if cfg["gateway_vision_enabled"]:
|
||||
attribution = _attribution_headers()
|
||||
augmenter = VisionAugmenter(
|
||||
cfg["gateway_vision_url"],
|
||||
cfg["gateway_vision_model"],
|
||||
@@ -262,6 +379,13 @@ class GatewayRuntime:
|
||||
context_map=context_map,
|
||||
app_reference=app_reference,
|
||||
vision_dialect=cfg.get("gateway_thinking_dialect", "auto"),
|
||||
breaker=self._vision_breaker,
|
||||
max_retries=cfg.get("gateway_max_retries", config.MAX_RETRIES_DEFAULT),
|
||||
retry_backoff_ms=cfg.get(
|
||||
"gateway_retry_backoff_ms", config.RETRY_BACKOFF_MS_DEFAULT
|
||||
),
|
||||
referer=attribution.get("HTTP-Referer", ""),
|
||||
title=attribution.get("X-Title", ""),
|
||||
)
|
||||
messages = await augmenter.augment_messages(client, messages)
|
||||
self.vision_calls += augmenter.calls
|
||||
@@ -301,6 +425,7 @@ class GatewayRuntime:
|
||||
capabilities = upstream_capabilities(
|
||||
cfg.get("gateway_upstream_url", ""),
|
||||
cfg.get("gateway_thinking_dialect", "auto"),
|
||||
ollama_stream_usage=bool(cfg.get("gateway_ollama_stream_usage", False)),
|
||||
)
|
||||
if capabilities.supports_stream_options:
|
||||
stream_options = dict(body.get("stream_options") or {})
|
||||
@@ -323,7 +448,11 @@ class GatewayRuntime:
|
||||
dialect=cfg.get("gateway_thinking_dialect", "auto"),
|
||||
)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**_attribution_headers(),
|
||||
**_extra_provider_headers(cfg),
|
||||
}
|
||||
if cfg["gateway_api_key"]:
|
||||
headers["Authorization"] = f"Bearer {cfg['gateway_api_key']}"
|
||||
else:
|
||||
@@ -331,6 +460,35 @@ class GatewayRuntime:
|
||||
"No upstream API key configured (gateway_api_key / DEEPSEEK_API_KEY / OPENROUTER_API_KEY); upstream will likely reject the request"
|
||||
)
|
||||
|
||||
fallback_used_route = None
|
||||
context_window = _route_context_window(cfg, model)
|
||||
if context_window:
|
||||
estimated_tokens = _estimate_request_tokens(payload)
|
||||
if estimated_tokens > context_window:
|
||||
precheck_route = resolve_fallback(fallback_key, "chat")
|
||||
precheck_overlay = (
|
||||
chat_overlay(precheck_route.source_model, base_cfg)
|
||||
if precheck_route is not None
|
||||
else None
|
||||
)
|
||||
if precheck_overlay:
|
||||
fallback_window = _route_context_window(
|
||||
precheck_overlay, precheck_overlay["gateway_model"]
|
||||
)
|
||||
if not fallback_window or estimated_tokens <= fallback_window:
|
||||
log(
|
||||
f"model {model!r} context window ({context_window} tokens) is "
|
||||
f"smaller than the estimated request size (~{estimated_tokens} "
|
||||
f"tokens); skipping straight to fallback "
|
||||
f"{precheck_route.source_model!r} -> {precheck_overlay['gateway_model']!r}"
|
||||
)
|
||||
fallback_used_route = precheck_route.source_model
|
||||
cfg = {**base_cfg, **precheck_overlay}
|
||||
model = cfg["gateway_model"]
|
||||
payload = dict(payload)
|
||||
payload["model"] = model
|
||||
headers = _fallback_headers(cfg, "gateway_api_key")
|
||||
|
||||
send_start = time.monotonic()
|
||||
resp, exc, timing = await self._send(
|
||||
client,
|
||||
@@ -347,10 +505,18 @@ class GatewayRuntime:
|
||||
# The streamed response body hasn't been read yet; the shared error
|
||||
# handling below needs resp.text/.content, which requires an explicit
|
||||
# read for a stream=True response (a no-op if already buffered).
|
||||
await resp.aread()
|
||||
try:
|
||||
await resp.aread()
|
||||
except httpx.HTTPError as e: # noqa: BLE001 - a corrupt/truncated
|
||||
# error body must never crash the request; fall through with
|
||||
# whatever text/content httpx managed to buffer (possibly empty).
|
||||
log(f"failed to read non-200 stream error body: {e}")
|
||||
|
||||
requested_model = requested
|
||||
if _call_failed(resp, exc, timing):
|
||||
body_error = _embedded_failure_message(resp, stream)
|
||||
if fallback_used_route is None and _should_fallback(resp, exc, timing, body_error):
|
||||
if body_error:
|
||||
log(f"model {requested!r} returned 200 with an embedded error: {body_error}")
|
||||
fallback_route = resolve_fallback(fallback_key, "chat")
|
||||
fallback_overlay = (
|
||||
chat_overlay(fallback_route.source_model, base_cfg)
|
||||
@@ -362,6 +528,7 @@ class GatewayRuntime:
|
||||
f"model {requested!r} failed, falling back to "
|
||||
f"{fallback_route.source_model!r} -> {fallback_overlay['gateway_model']!r}"
|
||||
)
|
||||
fallback_used_route = fallback_route.source_model
|
||||
cfg = {**base_cfg, **fallback_overlay}
|
||||
model = cfg["gateway_model"]
|
||||
payload = dict(payload)
|
||||
@@ -380,7 +547,11 @@ class GatewayRuntime:
|
||||
stream=stream,
|
||||
)
|
||||
if resp is not None and stream and resp.status_code != 200:
|
||||
await resp.aread()
|
||||
try:
|
||||
await resp.aread()
|
||||
except httpx.HTTPError as e: # noqa: BLE001
|
||||
log(f"failed to read fallback non-200 stream error body: {e}")
|
||||
body_error = _embedded_failure_message(resp, stream)
|
||||
|
||||
pricing = pricing_from_cfg(cfg)
|
||||
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
|
||||
@@ -394,6 +565,8 @@ class GatewayRuntime:
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
"provider": cfg.get("gateway_provider_name") or "default",
|
||||
"fallback_used_route": fallback_used_route,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
@@ -459,6 +632,18 @@ class GatewayRuntime:
|
||||
content={"error": {"message": resp.text, "type": "upstream_error"}},
|
||||
headers=resp_headers,
|
||||
)
|
||||
if body_error:
|
||||
# 200 OK with the failure embedded in the body (see
|
||||
# _embedded_failure_message) - the fallback, if any, already ran
|
||||
# and also failed. Report it as the upstream error it is instead
|
||||
# of billing/recording it as a successful call.
|
||||
resp_headers = finalize(502, False, "upstream_error")
|
||||
log(f"chat upstream POST -> 200 with embedded error: {body_error}")
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": {"message": body_error, "type": "upstream_error"}},
|
||||
headers=resp_headers,
|
||||
)
|
||||
if stream:
|
||||
# Real upstream streaming: forward SSE chunks to the client as they
|
||||
# arrive (so TTFT/inter-token latency are genuine), and finalize the
|
||||
@@ -490,7 +675,7 @@ class GatewayRuntime:
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
except (ValueError, httpx.HTTPError):
|
||||
self.errors += 1
|
||||
resp_headers = finalize(502, False, "gateway")
|
||||
log("chat upstream returned 200 but body was not valid JSON")
|
||||
@@ -550,8 +735,21 @@ class GatewayRuntime:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
delta = (choices[0].get("delta") if choices else None) or {}
|
||||
if delta.get("content") or delta.get("reasoning_content"):
|
||||
if delta.get("content") or delta.get("reasoning") or delta.get("reasoning_content"):
|
||||
content_chunks += 1
|
||||
# OpenRouter (and providers it fronts) can report a mid-stream
|
||||
# failure as an SSE chunk carrying an `error` key or
|
||||
# finish_reason "error" instead of dropping the connection -
|
||||
# see openrouter.ai/docs/api-reference/errors. The bytes are
|
||||
# already committed to the client at this point so they must
|
||||
# still be relayed, but the ledger must not record this as a
|
||||
# clean success.
|
||||
chunk_error = chunk.get("error")
|
||||
if not chunk_error and choices and choices[0].get("finish_reason") == "error":
|
||||
chunk_error = choices[0].get("error") or "upstream reported finish_reason=error"
|
||||
if chunk_error:
|
||||
success = False
|
||||
error_category = "upstream_error"
|
||||
usage = chunk.get("usage")
|
||||
if usage:
|
||||
usage_captured = usage
|
||||
@@ -664,7 +862,11 @@ class GatewayRuntime:
|
||||
payload = dict(body)
|
||||
payload["model"] = model
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**_attribution_headers(),
|
||||
**_extra_provider_headers(cfg),
|
||||
}
|
||||
if cfg["gateway_embed_key"]:
|
||||
headers["Authorization"] = f"Bearer {cfg['gateway_embed_key']}"
|
||||
else:
|
||||
@@ -684,7 +886,11 @@ class GatewayRuntime:
|
||||
)
|
||||
|
||||
requested_model = requested
|
||||
if _call_failed(resp, exc, timing):
|
||||
fallback_used_route = None
|
||||
body_error = _embedded_failure_message(resp, False)
|
||||
if _should_fallback(resp, exc, timing, body_error):
|
||||
if body_error:
|
||||
log(f"embed model {requested!r} returned 200 with an embedded error: {body_error}")
|
||||
fallback_route = resolve_fallback(fallback_key, "embed")
|
||||
fallback_overlay = (
|
||||
embed_overlay(fallback_route.source_model, base_cfg)
|
||||
@@ -696,6 +902,7 @@ class GatewayRuntime:
|
||||
f"embed model {requested!r} failed, falling back to "
|
||||
f"{fallback_route.source_model!r} -> {fallback_overlay['gateway_embed_model']!r}"
|
||||
)
|
||||
fallback_used_route = fallback_route.source_model
|
||||
cfg = {**base_cfg, **fallback_overlay}
|
||||
model = cfg["gateway_embed_model"]
|
||||
payload = dict(payload)
|
||||
@@ -711,6 +918,7 @@ class GatewayRuntime:
|
||||
log,
|
||||
json_body=payload,
|
||||
)
|
||||
body_error = _embedded_failure_message(resp, False)
|
||||
|
||||
pricing = pricing_from_cfg(cfg)
|
||||
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
|
||||
@@ -724,6 +932,8 @@ class GatewayRuntime:
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
"provider": cfg.get("gateway_provider_name") or "default",
|
||||
"fallback_used_route": fallback_used_route,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
@@ -789,9 +999,17 @@ class GatewayRuntime:
|
||||
content={"error": {"message": resp.text, "type": "upstream_error"}},
|
||||
headers=resp_headers,
|
||||
)
|
||||
if body_error:
|
||||
resp_headers = finalize(502, False, "upstream_error")
|
||||
log(f"embed upstream POST -> 200 with embedded error: {body_error}")
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": {"message": body_error, "type": "upstream_error"}},
|
||||
headers=resp_headers,
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
except (ValueError, httpx.HTTPError):
|
||||
self.errors += 1
|
||||
resp_headers = finalize(502, False, "gateway")
|
||||
log("embed upstream returned 200 but body was not valid JSON")
|
||||
@@ -874,7 +1092,11 @@ class GatewayRuntime:
|
||||
payload = dict(body)
|
||||
payload["model"] = model
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**_attribution_headers(),
|
||||
**_extra_provider_headers(cfg),
|
||||
}
|
||||
if cfg["gateway_image_key"]:
|
||||
headers["Authorization"] = f"Bearer {cfg['gateway_image_key']}"
|
||||
else:
|
||||
@@ -894,7 +1116,11 @@ class GatewayRuntime:
|
||||
)
|
||||
|
||||
requested_model = requested
|
||||
if _call_failed(resp, exc, timing):
|
||||
fallback_used_route = None
|
||||
body_error = _embedded_failure_message(resp, False)
|
||||
if _should_fallback(resp, exc, timing, body_error):
|
||||
if body_error:
|
||||
log(f"image model {requested!r} returned 200 with an embedded error: {body_error}")
|
||||
fallback_route = resolve_fallback(fallback_key, "image")
|
||||
fallback_overlay = (
|
||||
image_overlay(fallback_route.source_model, base_cfg)
|
||||
@@ -906,6 +1132,7 @@ class GatewayRuntime:
|
||||
f"image model {requested!r} failed, falling back to "
|
||||
f"{fallback_route.source_model!r} -> {fallback_overlay['gateway_image_model']!r}"
|
||||
)
|
||||
fallback_used_route = fallback_route.source_model
|
||||
cfg = {**base_cfg, **fallback_overlay}
|
||||
model = cfg["gateway_image_model"]
|
||||
payload = dict(payload)
|
||||
@@ -921,6 +1148,7 @@ class GatewayRuntime:
|
||||
log,
|
||||
json_body=payload,
|
||||
)
|
||||
body_error = _embedded_failure_message(resp, False)
|
||||
|
||||
pricing = pricing_from_cfg(cfg)
|
||||
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
|
||||
@@ -934,6 +1162,8 @@ class GatewayRuntime:
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
"provider": cfg.get("gateway_provider_name") or "default",
|
||||
"fallback_used_route": fallback_used_route,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
@@ -994,9 +1224,17 @@ class GatewayRuntime:
|
||||
content={"error": {"message": resp.text, "type": "upstream_error"}},
|
||||
headers=resp_headers,
|
||||
)
|
||||
if body_error:
|
||||
resp_headers = finalize(502, False, "upstream_error")
|
||||
log(f"image upstream POST -> 200 with embedded error: {body_error}")
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": {"message": body_error, "type": "upstream_error"}},
|
||||
headers=resp_headers,
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
except (ValueError, httpx.HTTPError):
|
||||
self.errors += 1
|
||||
resp_headers = finalize(502, False, "gateway")
|
||||
log("image upstream returned 200 but body was not valid JSON")
|
||||
@@ -1113,7 +1351,7 @@ class GatewayRuntime:
|
||||
):
|
||||
try:
|
||||
usage = resp.json().get("usage")
|
||||
except ValueError:
|
||||
except (ValueError, httpx.HTTPError):
|
||||
usage = None
|
||||
resp_headers = finalize(
|
||||
resp.status_code,
|
||||
@@ -1146,4 +1384,5 @@ class GatewayRuntime:
|
||||
"last_latency_ms": self.last_latency_ms,
|
||||
"pool": self._instances,
|
||||
"circuit_open": self._breaker.is_open,
|
||||
"vision_circuit_open": self._vision_breaker.is_open,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NEUTRAL_REWARD = 0.5
|
||||
WEIGHT_PRIOR_ATTEMPTS = 2.0
|
||||
PERFORMANCE_REFERENCE_TOKENS_PER_SECOND = 25.0
|
||||
PERFORMANCE_LATENCY_REFERENCE_MS = 8000.0
|
||||
CIRCUIT_BREAKER_FAILURE_THRESHOLD = 3
|
||||
CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0
|
||||
SEED_HISTORY_DAYS = 7
|
||||
|
||||
|
||||
def speed_reward(tokens_per_second: Optional[float]) -> float:
|
||||
if not tokens_per_second or tokens_per_second <= 0:
|
||||
return NEUTRAL_REWARD
|
||||
return tokens_per_second / (tokens_per_second + PERFORMANCE_REFERENCE_TOKENS_PER_SECOND)
|
||||
|
||||
|
||||
def latency_reward(latency_ms: Optional[float]) -> float:
|
||||
if not latency_ms or latency_ms <= 0:
|
||||
return 1.0
|
||||
return 1.0 / (1.0 + latency_ms / PERFORMANCE_LATENCY_REFERENCE_MS)
|
||||
|
||||
|
||||
def outcome_reward(
|
||||
latency_ms: Optional[float] = None, tokens_per_second: Optional[float] = None
|
||||
) -> float:
|
||||
return speed_reward(tokens_per_second) * latency_reward(latency_ms)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelHealth:
|
||||
consecutive_failures: int = 0
|
||||
disabled_until: float = 0.0
|
||||
times_chosen: int = 0
|
||||
last_used_at: float = 0.0
|
||||
success_count: int = 0
|
||||
failure_count: int = 0
|
||||
total_reward: float = 0.0
|
||||
total_latency_ms: float = 0.0
|
||||
total_tokens_per_second: float = 0.0
|
||||
|
||||
def is_circuit_open(self) -> bool:
|
||||
return self.disabled_until > time.time()
|
||||
|
||||
def weight(self) -> float:
|
||||
attempts = self.success_count + self.failure_count
|
||||
return (self.total_reward + NEUTRAL_REWARD * WEIGHT_PRIOR_ATTEMPTS) / (
|
||||
attempts + WEIGHT_PRIOR_ATTEMPTS
|
||||
)
|
||||
|
||||
def avg_latency_ms(self) -> Optional[float]:
|
||||
return (self.total_latency_ms / self.success_count) if self.success_count else None
|
||||
|
||||
def avg_tokens_per_second(self) -> Optional[float]:
|
||||
return (
|
||||
(self.total_tokens_per_second / self.success_count)
|
||||
if self.success_count
|
||||
else None
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return {
|
||||
"times_chosen": self.times_chosen,
|
||||
"consecutive_failures": self.consecutive_failures,
|
||||
"circuit_open": self.is_circuit_open(),
|
||||
"last_used_at": self.last_used_at,
|
||||
"success_count": self.success_count,
|
||||
"failure_count": self.failure_count,
|
||||
"weight": round(self.weight(), 4),
|
||||
"avg_latency_ms": self.avg_latency_ms(),
|
||||
"avg_tokens_per_second": self.avg_tokens_per_second(),
|
||||
}
|
||||
|
||||
|
||||
Key = tuple[str, str]
|
||||
|
||||
_health: dict[Key, ModelHealth] = {}
|
||||
|
||||
|
||||
def _health_for(provider: str, model: str) -> ModelHealth:
|
||||
key = (provider or "default", model or "")
|
||||
health = _health.get(key)
|
||||
if health is None:
|
||||
health = ModelHealth()
|
||||
_health[key] = health
|
||||
return health
|
||||
|
||||
|
||||
def record_outcome(
|
||||
provider: str,
|
||||
model: str,
|
||||
success: bool,
|
||||
latency_ms: Optional[float] = None,
|
||||
tokens_per_second: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Track per-(provider, model) reliability/speed/latency, purely for
|
||||
display on the stats page. Never gates or influences dispatch - routing
|
||||
stays exactly one target + one fallback hop, unchanged."""
|
||||
if not model:
|
||||
return
|
||||
health = _health_for(provider, model)
|
||||
health.times_chosen += 1
|
||||
health.last_used_at = time.time()
|
||||
if success:
|
||||
reward = (
|
||||
NEUTRAL_REWARD
|
||||
if latency_ms is None and tokens_per_second is None
|
||||
else outcome_reward(latency_ms, tokens_per_second)
|
||||
)
|
||||
health.success_count += 1
|
||||
health.consecutive_failures = 0
|
||||
health.disabled_until = 0.0
|
||||
health.total_reward += reward
|
||||
if latency_ms is not None:
|
||||
health.total_latency_ms += latency_ms
|
||||
if tokens_per_second is not None:
|
||||
health.total_tokens_per_second += tokens_per_second
|
||||
else:
|
||||
health.failure_count += 1
|
||||
health.consecutive_failures += 1
|
||||
if health.consecutive_failures >= CIRCUIT_BREAKER_FAILURE_THRESHOLD:
|
||||
health.disabled_until = time.time() + CIRCUIT_BREAKER_COOLDOWN_SECONDS
|
||||
|
||||
|
||||
def apply_history(
|
||||
provider: str, model: str, success_count: int, failure_count: int, total_reward: float
|
||||
) -> None:
|
||||
health = _health_for(provider, model)
|
||||
health.success_count += success_count
|
||||
health.failure_count += failure_count
|
||||
health.total_reward += total_reward
|
||||
|
||||
|
||||
def snapshot_all() -> dict:
|
||||
return {
|
||||
f"{provider}:{model}": health.snapshot()
|
||||
for (provider, model), health in _health.items()
|
||||
}
|
||||
|
||||
|
||||
def snapshot_for(provider: str, model: str) -> Optional[dict]:
|
||||
key = (provider or "default", model or "")
|
||||
health = _health.get(key)
|
||||
return health.snapshot() if health is not None else None
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Test-only: clear all in-memory health state."""
|
||||
_health.clear()
|
||||
|
||||
|
||||
def seed_from_ledger(days: int = SEED_HISTORY_DAYS) -> int:
|
||||
"""Fold `days` of existing gateway_usage_ledger rows into the in-memory
|
||||
health state at startup, so a restart doesn't forget every model's track
|
||||
record. Reuses data already being recorded - no new table."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.database import db
|
||||
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
|
||||
|
||||
# database/schema.py's init_db() is the single point of truth that
|
||||
# guarantees the provider column and its index exist before any request
|
||||
# is ever served - no defensive existence check needed here.
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(
|
||||
timespec="microseconds"
|
||||
)
|
||||
try:
|
||||
rows = db.query(
|
||||
f"SELECT provider, model, success, total_latency_ms, tokens_per_second "
|
||||
f"FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff AND model != ''",
|
||||
cutoff=cutoff,
|
||||
)
|
||||
grouped: dict[Key, dict] = {}
|
||||
for row in rows:
|
||||
provider = str(row.get("provider") or "default")
|
||||
model = str(row.get("model") or "")
|
||||
if not model:
|
||||
continue
|
||||
key = (provider, model)
|
||||
agg = grouped.setdefault(
|
||||
key, {"success_count": 0, "failure_count": 0, "total_reward": 0.0}
|
||||
)
|
||||
if row.get("success"):
|
||||
agg["success_count"] += 1
|
||||
agg["total_reward"] += outcome_reward(
|
||||
row.get("total_latency_ms"), row.get("tokens_per_second")
|
||||
)
|
||||
else:
|
||||
agg["failure_count"] += 1
|
||||
for (provider, model), agg in grouped.items():
|
||||
apply_history(
|
||||
provider,
|
||||
model,
|
||||
agg["success_count"],
|
||||
agg["failure_count"],
|
||||
agg["total_reward"],
|
||||
)
|
||||
return len(grouped)
|
||||
except Exception as exc: # noqa: BLE001 - startup seeding must never crash boot
|
||||
logger.warning("model health seed from ledger failed: %s", exc)
|
||||
return 0
|
||||
@@ -0,0 +1,368 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import db
|
||||
from devplacepy.services.openai_gateway import model_health
|
||||
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
|
||||
|
||||
RANGE_SECONDS = {
|
||||
"1h": 3600,
|
||||
"12h": 12 * 3600,
|
||||
"24h": 24 * 3600,
|
||||
"48h": 48 * 3600,
|
||||
"7d": 7 * 86400,
|
||||
"14d": 14 * 86400,
|
||||
"30d": 30 * 86400,
|
||||
"60d": 60 * 86400,
|
||||
"180d": 180 * 86400,
|
||||
}
|
||||
|
||||
LATENCY_BUCKET_EDGES_MS = (200, 500, 1000, 2000, 5000, 15000)
|
||||
LATENCY_BUCKET_LABELS = ("<200ms", "200-500ms", "500ms-1s", "1-2s", "2-5s", "5-15s", "15s+")
|
||||
TPS_BUCKET_EDGES = (5, 15, 30, 60, 120)
|
||||
TPS_BUCKET_LABELS = ("<5", "5-15", "15-30", "30-60", "60-120", "120+")
|
||||
|
||||
RECENT_FAILURES_LIMIT = 50
|
||||
|
||||
|
||||
def _cutoff_iso(range_seconds: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(seconds=range_seconds)).isoformat(
|
||||
timespec="microseconds"
|
||||
)
|
||||
|
||||
|
||||
def _bucket_seconds(range_seconds: int) -> int:
|
||||
return max(60, range_seconds // 120)
|
||||
|
||||
|
||||
def _histogram(values: list[float], edges: tuple, labels: tuple) -> list[dict]:
|
||||
counts = [0] * len(labels)
|
||||
for value in values:
|
||||
placed = False
|
||||
for index, edge in enumerate(edges):
|
||||
if value < edge:
|
||||
counts[index] += 1
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
counts[-1] += 1
|
||||
return [{"label": label, "count": count} for label, count in zip(labels, counts)]
|
||||
|
||||
|
||||
def _ledger_rows(cutoff: str) -> list[dict]:
|
||||
# database/schema.py's init_db() is the single point of truth that
|
||||
# guarantees every gateway_usage_ledger column (including provider and
|
||||
# fallback_used_route) and its indexes exist before any request is ever
|
||||
# served - no defensive existence checks needed here.
|
||||
return list(
|
||||
db.query(
|
||||
f"SELECT created_at, model, provider, endpoint, backend, success, "
|
||||
f"status_code, total_latency_ms, tokens_per_second, prompt_tokens, "
|
||||
f"completion_tokens, error_category, stream_requested, fallback_used_route "
|
||||
f"FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff",
|
||||
cutoff=cutoff,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bump(counter: dict, key, amount: int = 1) -> None:
|
||||
counter[key] = counter.get(key, 0) + amount
|
||||
|
||||
|
||||
def _resolve_range(range_key: str) -> int:
|
||||
if range_key not in RANGE_SECONDS:
|
||||
raise ValueError(f"Unknown range: {range_key}")
|
||||
return RANGE_SECONDS[range_key]
|
||||
|
||||
|
||||
def compute_summary(range_key: str) -> dict:
|
||||
range_seconds = _resolve_range(range_key)
|
||||
cutoff = _cutoff_iso(range_seconds)
|
||||
rows = _ledger_rows(cutoff)
|
||||
bucket_seconds = _bucket_seconds(range_seconds)
|
||||
|
||||
totals = {"success": 0, "error": 0}
|
||||
per_model: dict[tuple, dict] = {}
|
||||
per_endpoint: dict[str, dict] = {}
|
||||
per_provider: dict[str, dict] = {}
|
||||
status_codes: dict[int, int] = {}
|
||||
streaming_split = {"streamed": 0, "non_streamed": 0}
|
||||
failure_reasons: dict[str, int] = {}
|
||||
hourly: dict[int, int] = {}
|
||||
timeseries: dict[int, dict] = {}
|
||||
latency_values: list[float] = []
|
||||
tps_values: list[float] = []
|
||||
recent_failures: list[dict] = []
|
||||
|
||||
for row in rows:
|
||||
success = bool(row.get("success"))
|
||||
totals["success" if success else "error"] += 1
|
||||
if not success:
|
||||
recent_failures.append(
|
||||
{
|
||||
"created_at": row.get("created_at"),
|
||||
"model": str(row.get("model") or ""),
|
||||
"provider": str(row.get("provider") or "default"),
|
||||
"endpoint": row.get("endpoint"),
|
||||
"status_code": row.get("status_code"),
|
||||
"reason": row.get("error_category"),
|
||||
"fallback_used_route": row.get("fallback_used_route"),
|
||||
}
|
||||
)
|
||||
|
||||
model = str(row.get("model") or "")
|
||||
provider = str(row.get("provider") or "default")
|
||||
key = (model, provider)
|
||||
bucket = per_model.setdefault(
|
||||
key,
|
||||
{
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"total_requests": 0,
|
||||
"success_requests": 0,
|
||||
"_latency_sum": 0.0,
|
||||
"_latency_n": 0,
|
||||
"_tps_sum": 0.0,
|
||||
"_tps_n": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
},
|
||||
)
|
||||
bucket["total_requests"] += 1
|
||||
bucket["prompt_tokens"] += int(row.get("prompt_tokens") or 0)
|
||||
bucket["completion_tokens"] += int(row.get("completion_tokens") or 0)
|
||||
latency = row.get("total_latency_ms") or 0.0
|
||||
tps = row.get("tokens_per_second") or 0.0
|
||||
if success:
|
||||
bucket["success_requests"] += 1
|
||||
if latency:
|
||||
bucket["_latency_sum"] += latency
|
||||
bucket["_latency_n"] += 1
|
||||
latency_values.append(latency)
|
||||
if tps and tps > 0:
|
||||
bucket["_tps_sum"] += tps
|
||||
bucket["_tps_n"] += 1
|
||||
tps_values.append(tps)
|
||||
|
||||
endpoint_bucket = per_endpoint.setdefault(
|
||||
str(row.get("endpoint") or ""), {"total_requests": 0, "success_requests": 0}
|
||||
)
|
||||
endpoint_bucket["total_requests"] += 1
|
||||
endpoint_bucket["success_requests"] += int(success)
|
||||
|
||||
provider_bucket = per_provider.setdefault(
|
||||
provider, {"total_requests": 0, "success_requests": 0}
|
||||
)
|
||||
provider_bucket["total_requests"] += 1
|
||||
provider_bucket["success_requests"] += int(success)
|
||||
|
||||
if row.get("status_code"):
|
||||
_bump(status_codes, int(row["status_code"]))
|
||||
streaming_split["streamed" if row.get("stream_requested") else "non_streamed"] += 1
|
||||
if not success and row.get("error_category"):
|
||||
_bump(failure_reasons, str(row["error_category"]))
|
||||
|
||||
created_at = str(row.get("created_at") or "")
|
||||
if len(created_at) >= 13:
|
||||
_bump(hourly, int(created_at[11:13]))
|
||||
epoch = datetime.fromisoformat(created_at).timestamp()
|
||||
slot = int(epoch // bucket_seconds) * bucket_seconds
|
||||
ts_bucket = timeseries.setdefault(slot, {"requests": 0, "success": 0})
|
||||
ts_bucket["requests"] += 1
|
||||
ts_bucket["success"] += int(success)
|
||||
|
||||
per_model_out = []
|
||||
for (model, provider), bucket in per_model.items():
|
||||
health = model_health.snapshot_for(provider, model)
|
||||
per_model_out.append(
|
||||
{
|
||||
"model": bucket["model"],
|
||||
"provider": bucket["provider"],
|
||||
"total_requests": bucket["total_requests"],
|
||||
"success_requests": bucket["success_requests"],
|
||||
"avg_latency_ms": (
|
||||
round(bucket["_latency_sum"] / bucket["_latency_n"], 2)
|
||||
if bucket["_latency_n"]
|
||||
else None
|
||||
),
|
||||
"avg_tokens_per_second": (
|
||||
round(bucket["_tps_sum"] / bucket["_tps_n"], 2)
|
||||
if bucket["_tps_n"]
|
||||
else None
|
||||
),
|
||||
"prompt_tokens": bucket["prompt_tokens"],
|
||||
"completion_tokens": bucket["completion_tokens"],
|
||||
"health": health,
|
||||
}
|
||||
)
|
||||
per_model_out.sort(key=lambda r: r["total_requests"], reverse=True)
|
||||
|
||||
return {
|
||||
"range": range_key,
|
||||
"range_seconds": range_seconds,
|
||||
"bucket_seconds": bucket_seconds,
|
||||
"generated_at": time.time(),
|
||||
"totals": totals,
|
||||
"per_model": per_model_out,
|
||||
"per_endpoint": [
|
||||
{"endpoint": endpoint, **bucket} for endpoint, bucket in per_endpoint.items()
|
||||
],
|
||||
"per_provider": [
|
||||
{"provider": provider, **bucket} for provider, bucket in per_provider.items()
|
||||
],
|
||||
"timeseries": [
|
||||
{"bucket_start": slot, **bucket}
|
||||
for slot, bucket in sorted(timeseries.items())
|
||||
],
|
||||
"status_codes": [
|
||||
{"status_code": code, "count": count} for code, count in sorted(status_codes.items())
|
||||
],
|
||||
"streaming_split": streaming_split,
|
||||
"failure_reasons": sorted(
|
||||
({"reason": reason, "count": count} for reason, count in failure_reasons.items()),
|
||||
key=lambda r: r["count"],
|
||||
reverse=True,
|
||||
)[:10],
|
||||
"hourly_distribution": [
|
||||
{"hour": hour, "count": count} for hour, count in sorted(hourly.items())
|
||||
],
|
||||
"recent_failures": sorted(
|
||||
recent_failures, key=lambda r: r["created_at"] or "", reverse=True
|
||||
)[:RECENT_FAILURES_LIMIT],
|
||||
"latency_histogram": _histogram(
|
||||
latency_values, LATENCY_BUCKET_EDGES_MS, LATENCY_BUCKET_LABELS
|
||||
),
|
||||
"tokens_per_second_histogram": _histogram(
|
||||
tps_values, TPS_BUCKET_EDGES, TPS_BUCKET_LABELS
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute_model_detail(provider: str, model: str, range_key: str) -> dict:
|
||||
range_seconds = _resolve_range(range_key)
|
||||
cutoff = _cutoff_iso(range_seconds)
|
||||
bucket_seconds = _bucket_seconds(range_seconds)
|
||||
rows = [
|
||||
row
|
||||
for row in _ledger_rows(cutoff)
|
||||
if str(row.get("model") or "") == model
|
||||
and str(row.get("provider") or "default") == provider
|
||||
]
|
||||
|
||||
total_requests = len(rows)
|
||||
success_rows = [row for row in rows if row.get("success")]
|
||||
success_requests = len(success_rows)
|
||||
latencies = [row["total_latency_ms"] for row in success_rows if row.get("total_latency_ms")]
|
||||
tps_values = [
|
||||
row["tokens_per_second"] for row in success_rows if (row.get("tokens_per_second") or 0) > 0
|
||||
]
|
||||
status_codes: dict[int, int] = {}
|
||||
per_endpoint: dict[str, dict] = {}
|
||||
timeseries: dict[int, dict] = {}
|
||||
recent_failures = []
|
||||
|
||||
for row in rows:
|
||||
if row.get("status_code"):
|
||||
_bump(status_codes, int(row["status_code"]))
|
||||
endpoint_bucket = per_endpoint.setdefault(
|
||||
str(row.get("endpoint") or ""), {"total_requests": 0, "success_requests": 0}
|
||||
)
|
||||
endpoint_bucket["total_requests"] += 1
|
||||
endpoint_bucket["success_requests"] += int(bool(row.get("success")))
|
||||
created_at = str(row.get("created_at") or "")
|
||||
if len(created_at) >= 13:
|
||||
epoch = datetime.fromisoformat(created_at).timestamp()
|
||||
slot = int(epoch // bucket_seconds) * bucket_seconds
|
||||
ts_bucket = timeseries.setdefault(slot, {"requests": 0, "success": 0})
|
||||
ts_bucket["requests"] += 1
|
||||
ts_bucket["success"] += int(bool(row.get("success")))
|
||||
if not row.get("success"):
|
||||
recent_failures.append(
|
||||
{
|
||||
"created_at": row.get("created_at"),
|
||||
"endpoint": row.get("endpoint"),
|
||||
"status_code": row.get("status_code"),
|
||||
"reason": row.get("error_category"),
|
||||
"fallback_used_route": row.get("fallback_used_route"),
|
||||
}
|
||||
)
|
||||
|
||||
recent_failures.sort(key=lambda r: r["created_at"] or "", reverse=True)
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"range": range_key,
|
||||
"range_seconds": range_seconds,
|
||||
"bucket_seconds": bucket_seconds,
|
||||
"generated_at": time.time(),
|
||||
"health": model_health.snapshot_for(provider, model),
|
||||
"summary": {
|
||||
"total_requests": total_requests,
|
||||
"success_requests": success_requests,
|
||||
"avg_latency_ms": round(sum(latencies) / len(latencies), 2) if latencies else None,
|
||||
"min_latency_ms": min(latencies) if latencies else None,
|
||||
"max_latency_ms": max(latencies) if latencies else None,
|
||||
"avg_tokens_per_second": (
|
||||
round(sum(tps_values) / len(tps_values), 2) if tps_values else None
|
||||
),
|
||||
"prompt_tokens": sum(int(row.get("prompt_tokens") or 0) for row in rows),
|
||||
"completion_tokens": sum(int(row.get("completion_tokens") or 0) for row in rows),
|
||||
},
|
||||
"timeseries": [
|
||||
{"bucket_start": slot, **bucket} for slot, bucket in sorted(timeseries.items())
|
||||
],
|
||||
"status_codes": [
|
||||
{"status_code": code, "count": count} for code, count in sorted(status_codes.items())
|
||||
],
|
||||
"per_endpoint": [
|
||||
{"endpoint": endpoint, **bucket} for endpoint, bucket in per_endpoint.items()
|
||||
],
|
||||
"recent_failures": recent_failures[:RECENT_FAILURES_LIMIT],
|
||||
"latency_histogram": _histogram(latencies, LATENCY_BUCKET_EDGES_MS, LATENCY_BUCKET_LABELS),
|
||||
"tokens_per_second_histogram": _histogram(tps_values, TPS_BUCKET_EDGES, TPS_BUCKET_LABELS),
|
||||
}
|
||||
|
||||
|
||||
def list_known_models() -> list[dict]:
|
||||
"""Every (provider, model) pair with either a configured route or recent
|
||||
traffic, enriched with health - the merge that lets a freshly-configured,
|
||||
never-called route still show up alongside historically active ones."""
|
||||
from devplacepy.services.openai_gateway.routing import model_store
|
||||
|
||||
seen: dict[tuple, dict] = {}
|
||||
for route in model_store.list():
|
||||
if route.get("kind") != "chat" or not route.get("is_active", True):
|
||||
continue
|
||||
provider = str(route.get("provider") or "default")
|
||||
model = str(route.get("target_model") or "")
|
||||
if not model:
|
||||
continue
|
||||
key = (provider, model)
|
||||
seen[key] = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"source_model": route.get("source_model"),
|
||||
"context_window": int(route.get("context_window") or 0),
|
||||
}
|
||||
for key_str in model_health.snapshot_all().keys():
|
||||
provider, _, model = key_str.partition(":")
|
||||
key = (provider, model)
|
||||
if key not in seen:
|
||||
seen[key] = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"source_model": None,
|
||||
"context_window": 0,
|
||||
}
|
||||
out = []
|
||||
for (provider, model), entry in seen.items():
|
||||
entry["health"] = model_health.snapshot_for(provider, model)
|
||||
out.append(entry)
|
||||
out.sort(key=lambda r: (r["provider"], r["model"]))
|
||||
return out
|
||||
@@ -0,0 +1,42 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
OPENCODE_CLIENT_NAME = os.environ.get("OPENCODE_CLIENT_NAME", "cli")
|
||||
OPENCODE_CLIENT_USER_AGENT = os.environ.get(
|
||||
"OPENCODE_CLIENT_USER_AGENT",
|
||||
"opencode/1.18.29 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.15",
|
||||
)
|
||||
|
||||
_OPCODE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
_OPCODE_ID_LENGTH = 26
|
||||
|
||||
|
||||
def _opencode_id() -> str:
|
||||
return "".join(secrets.choice(_OPCODE_ID_ALPHABET) for _ in range(_OPCODE_ID_LENGTH))
|
||||
|
||||
|
||||
_opencode_project_id = "global"
|
||||
_opencode_session_id = "ses_" + _opencode_id()
|
||||
|
||||
|
||||
def opencode_impersonation_headers() -> dict:
|
||||
"""Reproduce the exact headers the real opencode CLI sends.
|
||||
|
||||
OpenCode Zen's free tier only serves the official opencode client: no
|
||||
User-Agent -> 429 FreeUsageLimitError, a CLI User-Agent without a session
|
||||
id -> 400 MissingSessionID, the full set below -> 200. The session id is
|
||||
stable for the process lifetime; the request id is fresh every call -
|
||||
both generated the same way the real client does (26 random base-62
|
||||
characters, ses_/msg_ prefixed).
|
||||
"""
|
||||
return {
|
||||
"User-Agent": OPENCODE_CLIENT_USER_AGENT,
|
||||
"x-opencode-client": OPENCODE_CLIENT_NAME,
|
||||
"x-opencode-project": _opencode_project_id,
|
||||
"x-opencode-session": _opencode_session_id,
|
||||
"x-opencode-request": "msg_" + _opencode_id(),
|
||||
}
|
||||
@@ -410,10 +410,14 @@ def spent_24h(
|
||||
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,
|
||||
try:
|
||||
rows = list(
|
||||
db.query(
|
||||
f"SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM {GATEWAY_LEDGER} WHERE {where}",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("gateway quota spend query failed: %s", exc)
|
||||
return 0.0
|
||||
return float(rows[0].get("spent") or 0.0) if rows else 0.0
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
import httpx
|
||||
@@ -58,12 +59,53 @@ class CircuitBreaker:
|
||||
self.opened_at = time.monotonic()
|
||||
|
||||
|
||||
async def _backoff(backoff_ms: int, attempt: int) -> None:
|
||||
MAX_RETRY_AFTER_SECONDS = 30.0
|
||||
|
||||
|
||||
def _retry_after_seconds(resp: httpx.Response) -> Optional[float]:
|
||||
# DeepSeek documents no Retry-After; OpenRouter documents a standard
|
||||
# HTTP Retry-After (seconds, sometimes an HTTP-date) on 429/503 and
|
||||
# instructs clients to honor it - see openrouter.ai/docs/api-reference/errors.
|
||||
raw = resp.headers.get("retry-after")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except ValueError:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
when = parsedate_to_datetime(raw)
|
||||
if when.tzinfo is None:
|
||||
return None
|
||||
seconds = (when - datetime.now(timezone.utc)).total_seconds()
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
if seconds < 0:
|
||||
return 0.0
|
||||
return min(seconds, MAX_RETRY_AFTER_SECONDS)
|
||||
|
||||
|
||||
async def _backoff(
|
||||
backoff_ms: int, attempt: int, retry_after: Optional[float] = None
|
||||
) -> None:
|
||||
if retry_after is not None:
|
||||
if retry_after > 0:
|
||||
await asyncio.sleep(retry_after)
|
||||
return
|
||||
delay = max(0, backoff_ms) * attempt / 1000.0
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
def _is_retryable_status(status_code: int) -> bool:
|
||||
# 5xx is a transient upstream fault. 429 is a rate limit - both DeepSeek
|
||||
# and OpenRouter use it and OpenRouter explicitly documents retrying it
|
||||
# (with Retry-After honored); a single retry after backoff is safe since
|
||||
# gateway_max_retries already bounds the total attempts.
|
||||
return status_code >= 500 or status_code == 429
|
||||
|
||||
|
||||
async def retry_send(
|
||||
do_call: Callable[[], Awaitable[httpx.Response]],
|
||||
sem: asyncio.Semaphore,
|
||||
@@ -95,13 +137,17 @@ async def retry_send(
|
||||
)
|
||||
await _backoff(backoff_ms, attempts)
|
||||
continue
|
||||
if resp.status_code >= 500 and attempts <= max_retries:
|
||||
log(f"upstream {resp.status_code}, retrying ({attempts}/{max_retries})")
|
||||
if _is_retryable_status(resp.status_code) and attempts <= max_retries:
|
||||
retry_after = _retry_after_seconds(resp)
|
||||
log(
|
||||
f"upstream {resp.status_code}, retrying ({attempts}/{max_retries})"
|
||||
+ (f" after Retry-After={retry_after:.1f}s" if retry_after else "")
|
||||
)
|
||||
try:
|
||||
await resp.aclose()
|
||||
except Exception: # noqa: BLE001 - releasing the connection must never block a retry
|
||||
pass
|
||||
await _backoff(backoff_ms, attempts)
|
||||
await _backoff(backoff_ms, attempts, retry_after)
|
||||
continue
|
||||
return resp, None, attempts, queue_wait_ms
|
||||
return None, last_exc, attempts, queue_wait_ms
|
||||
|
||||
@@ -15,6 +15,7 @@ from devplacepy.database import (
|
||||
get_table,
|
||||
sync_local_cache,
|
||||
)
|
||||
from devplacepy.services.openai_gateway import opencode_zen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,6 +24,18 @@ MODELS_TABLE = "gateway_models"
|
||||
CACHE_NAME = "gateway_routing"
|
||||
KINDS = ("chat", "embed", "image")
|
||||
|
||||
# Admin-selectable on a gateway_providers row's `client_profile` field. A
|
||||
# generic provider (the default, empty profile) sends no extra headers -
|
||||
# this exists only for upstreams that reject requests unless they see a
|
||||
# specific client's headers, e.g. OpenCode Zen's free tier only serves the
|
||||
# real opencode CLI (spoofed via opencode_zen.opencode_impersonation_headers)
|
||||
# and returns 429/400 to anything else. Adding a new profile is one entry
|
||||
# here plus one option in the provider form - no other special-casing.
|
||||
CLIENT_PROFILES: dict = {
|
||||
"": None,
|
||||
"opencode": opencode_zen.opencode_impersonation_headers,
|
||||
}
|
||||
|
||||
_ROUTING_CACHE: dict = {}
|
||||
|
||||
|
||||
@@ -56,6 +69,63 @@ def _image_url_from_base(base_url: str) -> str:
|
||||
return base_url
|
||||
|
||||
|
||||
def _models_url_from_base(base_url: str) -> str:
|
||||
base_url = (base_url or "").strip()
|
||||
if not base_url:
|
||||
return ""
|
||||
if base_url.endswith("/chat/completions"):
|
||||
return base_url[: -len("/chat/completions")] + "/models"
|
||||
return base_url.rstrip("/") + "/models"
|
||||
|
||||
|
||||
PROVIDER_MODELS_PROBE_TIMEOUT = 8.0
|
||||
|
||||
|
||||
def _default_provider_credentials() -> tuple[str, str]:
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
svc = service_manager.get_service("openai")
|
||||
cfg = svc.get_config() if svc is not None else {}
|
||||
return cfg.get("gateway_upstream_url", ""), cfg.get("gateway_api_key", "")
|
||||
|
||||
|
||||
async def fetch_provider_models(
|
||||
provider_name: str, timeout: float = PROVIDER_MODELS_PROBE_TIMEOUT
|
||||
) -> Optional[list[str]]:
|
||||
provider_name = (provider_name or "").strip()
|
||||
if provider_name:
|
||||
provider = provider_store.get(provider_name)
|
||||
if provider is None:
|
||||
return None
|
||||
base_url, api_key = provider.get("base_url", ""), provider.get("api_key", "")
|
||||
else:
|
||||
base_url, api_key = _default_provider_credentials()
|
||||
models_url = _models_url_from_base(base_url)
|
||||
if not models_url:
|
||||
return None
|
||||
|
||||
from devplacepy import stealth
|
||||
|
||||
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
try:
|
||||
async with stealth.stealth_async_client(timeout=timeout, headers=headers) as client:
|
||||
response = await client.get(models_url)
|
||||
except Exception as exc:
|
||||
logger.info("gateway provider model listing failed for %s: %s", models_url, exc)
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
return None
|
||||
rows = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
ids = [str(row["id"]) for row in rows if isinstance(row, dict) and row.get("id")]
|
||||
return ids or None
|
||||
|
||||
|
||||
MODEL_TIER2_COLUMNS = (
|
||||
"context_tier_threshold_tokens",
|
||||
"price_cache_hit_per_m_tier2",
|
||||
@@ -73,8 +143,12 @@ def ensure_tables() -> None:
|
||||
"CREATE TABLE IF NOT EXISTS "
|
||||
+ PROVIDERS_TABLE
|
||||
+ " (id INTEGER PRIMARY KEY, name TEXT, base_url TEXT, api_key TEXT, "
|
||||
"is_active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT)"
|
||||
"is_active INTEGER DEFAULT 1, client_profile TEXT DEFAULT '', "
|
||||
"created_at TEXT, updated_at TEXT)"
|
||||
)
|
||||
providers_table = get_table(PROVIDERS_TABLE)
|
||||
if not providers_table.has_column("client_profile"):
|
||||
providers_table.create_column_by_example("client_profile", "")
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS "
|
||||
+ MODELS_TABLE
|
||||
@@ -121,6 +195,7 @@ class ProviderIn(BaseModel):
|
||||
base_url: str = Field(default="", max_length=500)
|
||||
api_key: str = Field(default="", max_length=400)
|
||||
is_active: bool = True
|
||||
client_profile: str = Field(default="", max_length=32)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -140,6 +215,16 @@ class ProviderIn(BaseModel):
|
||||
raise ValueError("Base URL must be a http(s) URL")
|
||||
return value
|
||||
|
||||
@field_validator("client_profile")
|
||||
@classmethod
|
||||
def _clean_client_profile(cls, value: str) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
if value not in CLIENT_PROFILES:
|
||||
raise ValueError(
|
||||
f"client_profile must be one of: {', '.join(sorted(CLIENT_PROFILES)) or '(none)'}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class ModelRouteIn(BaseModel):
|
||||
source_model: str = Field(min_length=1, max_length=128)
|
||||
@@ -280,6 +365,7 @@ def _load() -> dict:
|
||||
"base_url": str(row.get("base_url") or ""),
|
||||
"api_key": str(row.get("api_key") or ""),
|
||||
"is_active": _as_bool(row.get("is_active", 1)),
|
||||
"client_profile": str(row.get("client_profile") or ""),
|
||||
}
|
||||
if MODELS_TABLE in db.tables:
|
||||
for row in get_table(MODELS_TABLE).all():
|
||||
@@ -317,6 +403,7 @@ class ProviderStore:
|
||||
"base_url": payload.base_url,
|
||||
"api_key": payload.api_key,
|
||||
"is_active": 1 if payload.is_active else 0,
|
||||
"client_profile": payload.client_profile,
|
||||
"updated_at": _now(),
|
||||
}
|
||||
if existing:
|
||||
@@ -500,6 +587,15 @@ def seed_default_deepseek_routes() -> None:
|
||||
_ROUTING_CACHE.clear()
|
||||
|
||||
|
||||
def _header_hook_for(provider_name: str):
|
||||
if not provider_name:
|
||||
return None
|
||||
provider = provider_store.get(provider_name)
|
||||
if provider is None:
|
||||
return None
|
||||
return CLIENT_PROFILES.get(provider.get("client_profile") or "")
|
||||
|
||||
|
||||
def _provider_overlay(name: str, base_key: str, url_key: str, overlay: dict) -> None:
|
||||
provider = provider_store.get(name)
|
||||
if provider is None:
|
||||
@@ -527,11 +623,15 @@ def chat_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dic
|
||||
"gateway_off_peak_start_minute": route.off_peak_start_minute,
|
||||
"gateway_off_peak_end_minute": route.off_peak_end_minute,
|
||||
"gateway_off_peak_discount_pct": route.off_peak_discount_pct,
|
||||
"gateway_provider_name": route.provider or "default",
|
||||
}
|
||||
if route.provider:
|
||||
_provider_overlay(
|
||||
route.provider, "gateway_api_key", "gateway_upstream_url", overlay
|
||||
)
|
||||
header_hook = _header_hook_for(route.provider)
|
||||
if header_hook is not None:
|
||||
overlay["gateway_extra_request_headers"] = header_hook()
|
||||
if route.context_window:
|
||||
from devplacepy.services.openai_gateway.usage import parse_context_map
|
||||
|
||||
@@ -570,6 +670,7 @@ def embed_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[di
|
||||
"gateway_off_peak_start_minute": route.off_peak_start_minute,
|
||||
"gateway_off_peak_end_minute": route.off_peak_end_minute,
|
||||
"gateway_off_peak_discount_pct": route.off_peak_discount_pct,
|
||||
"gateway_provider_name": route.provider or "default",
|
||||
}
|
||||
provider = provider_store.get(route.provider) if route.provider else None
|
||||
if provider:
|
||||
@@ -577,6 +678,9 @@ def embed_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[di
|
||||
overlay["gateway_embed_url"] = _embed_url_from_base(provider["base_url"])
|
||||
if provider.get("api_key"):
|
||||
overlay["gateway_embed_key"] = provider["api_key"]
|
||||
header_hook = _header_hook_for(route.provider)
|
||||
if header_hook is not None:
|
||||
overlay["gateway_extra_request_headers"] = header_hook()
|
||||
return overlay
|
||||
|
||||
|
||||
@@ -591,6 +695,7 @@ def image_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[di
|
||||
"gateway_off_peak_start_minute": route.off_peak_start_minute,
|
||||
"gateway_off_peak_end_minute": route.off_peak_end_minute,
|
||||
"gateway_off_peak_discount_pct": route.off_peak_discount_pct,
|
||||
"gateway_provider_name": route.provider or "default",
|
||||
}
|
||||
provider = provider_store.get(route.provider) if route.provider else None
|
||||
if provider:
|
||||
@@ -598,6 +703,9 @@ def image_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[di
|
||||
overlay["gateway_image_url"] = _image_url_from_base(provider["base_url"])
|
||||
if provider.get("api_key"):
|
||||
overlay["gateway_image_key"] = provider["api_key"]
|
||||
header_hook = _header_hook_for(route.provider)
|
||||
if header_hook is not None:
|
||||
overlay["gateway_extra_request_headers"] = header_hook()
|
||||
return overlay
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,18 @@ class GatewayService(BaseService):
|
||||
"proxy - so thinking/stream_options are emitted in the right shape.",
|
||||
group="Prompt",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_ollama_stream_usage",
|
||||
"Ollama streamed usage",
|
||||
type="bool",
|
||||
default=False,
|
||||
help="Send stream_options.include_usage to an Ollama upstream so a "
|
||||
"streamed call's tokens/cost are metered. Current Ollama docs list this "
|
||||
"as supported, but older server builds reject or ignore it - off by "
|
||||
"default; enable it once the deployed Ollama version is confirmed to "
|
||||
"support it. Only applies when the upstream dialect resolves to Ollama.",
|
||||
group="Prompt",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_vision_enabled",
|
||||
"Vision augmentation",
|
||||
@@ -671,24 +683,36 @@ class GatewayService(BaseService):
|
||||
)
|
||||
if subpath == "models" and request.method == "GET":
|
||||
return self._models_response()
|
||||
if self.consent_denied(owner):
|
||||
self.log(
|
||||
f"Rejected {owner[0]}:{owner[1]} app={app_reference}: "
|
||||
f"third-party AI consent not granted"
|
||||
)
|
||||
self._audit_consent_denied(owner, app_reference)
|
||||
raise HTTPException(status_code=403, detail=CONSENT_REQUIRED_MESSAGE)
|
||||
limit, scope, rule = quota.resolve(owner[0], owner[1], app_reference, cfg)
|
||||
if limit > 0:
|
||||
spent = quota.spent_24h(*scope)
|
||||
if spent >= limit:
|
||||
try:
|
||||
if self.consent_denied(owner):
|
||||
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'})"
|
||||
f"third-party AI consent not granted"
|
||||
)
|
||||
self._audit_quota_exceeded(owner[0], owner[1], app_reference, spent, limit, rule)
|
||||
raise HTTPException(status_code=429, detail="AI gateway daily quota exceeded")
|
||||
self._audit_consent_denied(owner, app_reference)
|
||||
raise HTTPException(status_code=403, detail=CONSENT_REQUIRED_MESSAGE)
|
||||
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")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.log(
|
||||
f"Pre-flight check failed for {owner[0]}:{owner[1]} app={app_reference}: {exc}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="AI gateway temporarily unavailable"
|
||||
) from exc
|
||||
if subpath == "chat/completions" and request.method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
@@ -43,14 +43,18 @@ class UpstreamCapabilities:
|
||||
|
||||
|
||||
def upstream_capabilities(
|
||||
url: str, dialect: str = ""
|
||||
url: str, dialect: str = "", ollama_stream_usage: bool = False
|
||||
) -> UpstreamCapabilities:
|
||||
# Current Ollama docs list stream_options.include_usage as supported, but
|
||||
# real deployed server versions vary and some reject it outright (see
|
||||
# github.com/ollama/ollama issues #15288/#15293/#14820) - default off,
|
||||
# gateway_ollama_stream_usage lets an operator opt in for a known-good build.
|
||||
effective = _resolve_dialect(url, dialect)
|
||||
if effective == "ollama":
|
||||
return UpstreamCapabilities(
|
||||
dialect=effective,
|
||||
supports_stream_options=False,
|
||||
supports_stream_usage=False,
|
||||
supports_stream_options=bool(ollama_stream_usage),
|
||||
supports_stream_usage=bool(ollama_stream_usage),
|
||||
supports_thinking_field=True,
|
||||
)
|
||||
return UpstreamCapabilities(
|
||||
@@ -144,7 +148,15 @@ def write_thinking(payload: dict, dialect: str, enabled: bool) -> None:
|
||||
payload["reasoning"] = {"effort": "high" if enabled else "none"}
|
||||
return
|
||||
if dialect == "ollama":
|
||||
# Ollama's native /api/chat honors a boolean `think`, but its
|
||||
# OpenAI-compatible /v1/chat/completions layer does not (confirmed
|
||||
# against github.com/ollama/ollama issues #15288/#15293/#14820,
|
||||
# Sep 2026): that endpoint maps `reasoning_effort`/`reasoning.effort`
|
||||
# onto its internal think flag instead. Send both - `think` is
|
||||
# harmless if ignored, `reasoning_effort` is the field the
|
||||
# OpenAI-compat endpoint actually honors.
|
||||
payload["think"] = bool(enabled)
|
||||
payload["reasoning_effort"] = "high" if enabled else "none"
|
||||
return
|
||||
payload["thinking"] = {"type": "enabled" if enabled else "disabled"}
|
||||
|
||||
|
||||
@@ -350,6 +350,8 @@ def classify_error(
|
||||
return "rate_limit"
|
||||
if status_code in (401, 403):
|
||||
return "auth"
|
||||
if status_code == 402:
|
||||
return "insufficient_balance"
|
||||
if status_code == 404:
|
||||
return "model_not_found"
|
||||
if status_code in (400, 422):
|
||||
@@ -362,6 +364,32 @@ def classify_error(
|
||||
return "gateway"
|
||||
|
||||
|
||||
def embedded_error_message(data: Any) -> Optional[str]:
|
||||
"""Detect a provider-side failure reported with HTTP 200.
|
||||
|
||||
OpenRouter (and providers it fronts) can answer 200 OK with the failure
|
||||
embedded in the JSON body instead of a non-2xx status - documented at
|
||||
openrouter.ai/docs/api-reference/errors: "the HTTP response status is
|
||||
matched with the error.code for validation/credits issues, otherwise
|
||||
returns 200 OK with the error embedded in the response body." A caller
|
||||
that only checks status_code would silently record this as a success
|
||||
with no usage and never trigger a retry or model fallback.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
error = data.get("error")
|
||||
if not error:
|
||||
return None
|
||||
if "choices" in data or "data" in data:
|
||||
# A well-formed choices/data payload alongside a per-item `error`
|
||||
# field (e.g. a single failed choice) is not a whole-response failure.
|
||||
return None
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message") or error.get("code") or "unknown error"
|
||||
return str(message)[:500]
|
||||
return str(error)[:500]
|
||||
|
||||
|
||||
def audit_actor_for(owner_kind: str, owner_id: str) -> tuple[str, Optional[str], str]:
|
||||
actor_kind = (
|
||||
"guest"
|
||||
@@ -545,6 +573,12 @@ class GatewayUsageLedger:
|
||||
"endpoint": raw.get("endpoint") or "",
|
||||
"requested_model": raw.get("requested_model") or "",
|
||||
"model": raw.get("model") or "",
|
||||
"provider": (raw.get("provider") or "default")[:60],
|
||||
"fallback_used_route": (
|
||||
str(raw["fallback_used_route"])[:128]
|
||||
if raw.get("fallback_used_route")
|
||||
else None
|
||||
),
|
||||
"status_code": int(raw.get("status_code") or 0),
|
||||
"success": 1 if raw.get("success") else 0,
|
||||
"error_category": raw.get("error_category"),
|
||||
@@ -581,11 +615,29 @@ class GatewayUsageLedger:
|
||||
}
|
||||
get_table(GATEWAY_LEDGER).insert(row)
|
||||
self._audit(raw, norm, cost_usd)
|
||||
self._track_health(row)
|
||||
return row
|
||||
except Exception as exc:
|
||||
logger.warning("gateway usage record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _track_health(self, row: dict) -> None:
|
||||
# Observational only - never gates or influences dispatch. Isolated in
|
||||
# its own try/except so a health-tracking bug can never turn an
|
||||
# already-committed ledger write into a failed record() call.
|
||||
try:
|
||||
from devplacepy.services.openai_gateway import model_health
|
||||
|
||||
model_health.record_outcome(
|
||||
row["provider"],
|
||||
row["model"],
|
||||
bool(row["success"]),
|
||||
latency_ms=row.get("upstream_latency_ms") or None,
|
||||
tokens_per_second=row.get("tokens_per_second") or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("model health tracking failed: %s", exc)
|
||||
|
||||
def record_external(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -10,6 +10,11 @@ from typing import Any, Optional
|
||||
import httpx
|
||||
|
||||
from devplacepy.services.openai_gateway.config import VISION_INSTRUCTION
|
||||
from devplacepy.services.openai_gateway.reliability import (
|
||||
_backoff,
|
||||
_is_retryable_status,
|
||||
_retry_after_seconds,
|
||||
)
|
||||
from devplacepy.services.openai_gateway.thinking import apply_thinking
|
||||
from devplacepy.services.openai_gateway.usage import classify_error
|
||||
|
||||
@@ -97,6 +102,9 @@ class VisionAugmenter:
|
||||
pricing=None,
|
||||
context_map=None,
|
||||
app_reference: str = "default",
|
||||
breaker=None,
|
||||
max_retries: int = 2,
|
||||
retry_backoff_ms: int = 250,
|
||||
):
|
||||
self.vision_url = vision_url
|
||||
self.vision_model = vision_model
|
||||
@@ -110,6 +118,9 @@ class VisionAugmenter:
|
||||
self.pricing = pricing
|
||||
self.context_map = context_map or {}
|
||||
self.app_reference = app_reference
|
||||
self.breaker = breaker
|
||||
self.max_retries = max(0, int(max_retries or 0))
|
||||
self.retry_backoff_ms = max(0, int(retry_backoff_ms or 0))
|
||||
self.calls = 0
|
||||
self.cost_usd = 0.0
|
||||
|
||||
@@ -142,6 +153,9 @@ class VisionAugmenter:
|
||||
async def _describe_one(self, client: httpx.AsyncClient, image_block: dict) -> str:
|
||||
if not self.vision_key:
|
||||
return "[vision unavailable: vision API key not configured]"
|
||||
if self.breaker is not None and not self.breaker.allow():
|
||||
self._record((0.0), 503, False, "circuit_open", None)
|
||||
return "[vision unavailable: circuit breaker open]"
|
||||
payload = {
|
||||
"model": self.vision_model,
|
||||
"messages": [
|
||||
@@ -166,18 +180,49 @@ class VisionAugmenter:
|
||||
if self.title:
|
||||
headers["X-Title"] = self.title
|
||||
start = time.monotonic()
|
||||
try:
|
||||
resp = await client.post(self.vision_url, json=payload, headers=headers)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning("vision connection failed: %s", e)
|
||||
attempt = 0
|
||||
resp: Optional[httpx.Response] = None
|
||||
exc: Optional[Exception] = None
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
resp = await client.post(self.vision_url, json=payload, headers=headers)
|
||||
exc = None
|
||||
except httpx.RequestError as e:
|
||||
resp = None
|
||||
exc = e
|
||||
if exc is None and not _is_retryable_status(resp.status_code):
|
||||
break
|
||||
if attempt > self.max_retries:
|
||||
break
|
||||
retry_after = _retry_after_seconds(resp) if resp is not None else None
|
||||
logger.info(
|
||||
"vision call retrying (%d/%d): %s",
|
||||
attempt,
|
||||
self.max_retries,
|
||||
exc or f"HTTP {resp.status_code}",
|
||||
)
|
||||
if resp is not None:
|
||||
try:
|
||||
await resp.aclose()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await _backoff(self.retry_backoff_ms, attempt, retry_after)
|
||||
if self.breaker is not None:
|
||||
if exc is not None or (resp is not None and resp.status_code >= 500):
|
||||
self.breaker.record_failure()
|
||||
else:
|
||||
self.breaker.record_success()
|
||||
if exc is not None:
|
||||
logger.warning("vision connection failed: %s", exc)
|
||||
self._record(
|
||||
(time.monotonic() - start) * 1000,
|
||||
502,
|
||||
False,
|
||||
classify_error(0, e),
|
||||
classify_error(0, exc),
|
||||
None,
|
||||
)
|
||||
return f"[vision call failed: {e}]"
|
||||
return f"[vision call failed: {exc}]"
|
||||
latency_ms = (time.monotonic() - start) * 1000
|
||||
if resp.status_code != 200:
|
||||
logger.warning("vision %s: %s", resp.status_code, resp.text[:200])
|
||||
@@ -191,10 +236,15 @@ class VisionAugmenter:
|
||||
return f"[vision failed: HTTP {resp.status_code}]"
|
||||
try:
|
||||
data = resp.json()
|
||||
error = data.get("error") if isinstance(data, dict) else None
|
||||
if error and "choices" not in data:
|
||||
message = error.get("message") if isinstance(error, dict) else str(error)
|
||||
self._record(latency_ms, 502, False, "upstream_error", None)
|
||||
return f"[vision failed: {message}]"
|
||||
text = data["choices"][0]["message"].get("content") or ""
|
||||
self._record(latency_ms, 200, True, None, data.get("usage"))
|
||||
return text.strip() or "[vision returned empty response]"
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
except (KeyError, IndexError, ValueError, httpx.HTTPError) as e:
|
||||
self._record(latency_ms, 200, False, "gateway", None)
|
||||
return f"[vision parse error: {e}]"
|
||||
|
||||
|
||||
@@ -74,6 +74,58 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gw-inline-form {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.gw-page-form {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.gw-fieldset {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.gw-fieldset legend {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.gw-fieldset .gw-section-hint {
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
.gw-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.gw-page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.gw-error {
|
||||
background: var(--bg-card-hover);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
color: var(--danger);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: var(--space-md);
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.gw-form {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
@@ -121,6 +173,12 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gw-field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gw-field input,
|
||||
.gw-field select {
|
||||
width: 100%;
|
||||
@@ -152,3 +210,92 @@
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Stats tab ----------------------------------------------------------- */
|
||||
|
||||
.gw-stats-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.gw-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.gw-tile {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gw-tile-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gw-tile-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.gw-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.gw-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gw-panel.gw-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.gw-panel h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.gw-chart-box {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.gw-chart-box canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.gw-table-scroll {
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.gw-tiles {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.gw-chart-box {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,3 +418,131 @@
|
||||
.service-field-input {
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* ---- Devii tool catalog ---- */
|
||||
.devii-tools-intro {
|
||||
margin-bottom: var(--space-md);
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.devii-tools-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.devii-tools-search {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.devii-tools-summary {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.devii-tools-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.devii-tools-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.875rem;
|
||||
}
|
||||
|
||||
.devii-tools-group[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.devii-tools-group-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 0.625rem 0;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.devii-tools-group-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.devii-tools-group-summary::before {
|
||||
content: "\25B8";
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.devii-tools-group[open] > .devii-tools-group-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.devii-tools-group-checkbox,
|
||||
.devii-tools-item input[type="checkbox"] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.devii-tools-group-label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.devii-tools-group-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.devii-tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0 0.875rem 1.625rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.devii-tools-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.devii-tools-item[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.devii-tools-item-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.devii-tools-item-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.devii-tools-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--warning);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 999px;
|
||||
padding: 0.0625rem 0.4rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
class DeviiToolsConfig {
|
||||
constructor() {
|
||||
this.form = document.querySelector("[data-tools-form]");
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.form) return;
|
||||
this.groups = [...this.form.querySelectorAll("[data-tools-group]")];
|
||||
this.bindGroupToggles();
|
||||
this.bindItemChanges();
|
||||
this.bindSearch();
|
||||
this.bindSubmit();
|
||||
this.groups.forEach((group) => this.syncGroupState(group));
|
||||
this.updateSummary();
|
||||
}
|
||||
|
||||
bindGroupToggles() {
|
||||
this.form.querySelectorAll("[data-group-toggle]").forEach((checkbox) => {
|
||||
checkbox.addEventListener("click", (event) => event.stopPropagation());
|
||||
checkbox.addEventListener("change", () => {
|
||||
const group = checkbox.closest("[data-tools-group]");
|
||||
group.querySelectorAll('input[type="checkbox"][name="enabled"]').forEach((item) => {
|
||||
item.checked = checkbox.checked;
|
||||
});
|
||||
this.syncGroupState(group);
|
||||
this.updateSummary();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bindItemChanges() {
|
||||
this.form.addEventListener("change", (event) => {
|
||||
if (event.target.name !== "enabled") return;
|
||||
const group = event.target.closest("[data-tools-group]");
|
||||
if (group) this.syncGroupState(group);
|
||||
this.updateSummary();
|
||||
});
|
||||
}
|
||||
|
||||
syncGroupState(group) {
|
||||
const items = [...group.querySelectorAll('input[type="checkbox"][name="enabled"]')];
|
||||
const checked = items.filter((item) => item.checked).length;
|
||||
const toggle = group.querySelector("[data-group-toggle]");
|
||||
if (toggle) {
|
||||
toggle.checked = checked === items.length;
|
||||
toggle.indeterminate = checked > 0 && checked < items.length;
|
||||
}
|
||||
const countEl = group.querySelector(`[data-group-count="${group.dataset.toolsGroup}"]`);
|
||||
if (countEl) countEl.textContent = `${checked}/${items.length} enabled`;
|
||||
}
|
||||
|
||||
updateSummary() {
|
||||
const all = [...this.form.querySelectorAll('input[type="checkbox"][name="enabled"]')];
|
||||
const checked = all.filter((item) => item.checked).length;
|
||||
const summary = this.form.querySelector("[data-tools-summary]");
|
||||
if (summary) summary.textContent = `${checked} of ${all.length} tools enabled`;
|
||||
}
|
||||
|
||||
bindSearch() {
|
||||
const input = this.form.querySelector("[data-tools-search]");
|
||||
if (!input) return;
|
||||
input.addEventListener("input", () => {
|
||||
const term = input.value.trim().toLowerCase();
|
||||
this.groups.forEach((group) => {
|
||||
const items = [...group.querySelectorAll("[data-tools-item]")];
|
||||
let anyVisible = false;
|
||||
items.forEach((item) => {
|
||||
const matches = !term || item.dataset.toolsItemLabel.includes(term);
|
||||
item.hidden = !matches;
|
||||
if (matches) anyVisible = true;
|
||||
});
|
||||
group.hidden = !anyVisible;
|
||||
if (term && anyVisible) group.open = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bindSubmit() {
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.save();
|
||||
});
|
||||
}
|
||||
|
||||
async save() {
|
||||
const statusEl = this.form.querySelector("[data-tools-status]");
|
||||
const button = this.form.querySelector('button[type="submit"]');
|
||||
const params = new URLSearchParams();
|
||||
this.form.querySelectorAll('input[type="checkbox"][name="enabled"]:checked').forEach((item) => {
|
||||
params.append("enabled", item.value);
|
||||
});
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await Http.sendForm("/admin/services/devii/tools", params);
|
||||
if (statusEl) Toast.flash(statusEl, "Saved", 2000, "");
|
||||
} catch {
|
||||
if (statusEl) Toast.flash(statusEl, "Request failed", 2000, "");
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.DeviiToolsConfig = DeviiToolsConfig;
|
||||
@@ -1,503 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class GatewayAdmin {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.providersBody = root.querySelector("#gw-providers");
|
||||
this.modelsBody = root.querySelector("#gw-models");
|
||||
this.providerForm = root.querySelector("#gw-provider-form");
|
||||
this.modelForm = root.querySelector("#gw-model-form");
|
||||
this.providerSelects = root.querySelectorAll("[data-provider-select]");
|
||||
this.providers = [];
|
||||
this.models = [];
|
||||
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() {
|
||||
this.bind();
|
||||
this.syncKindFields();
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
syncKindFields() {
|
||||
const kind = this.modelForm.kind.value || "chat";
|
||||
const priceInputLabel = this.root.querySelector("#gw-model-price-input-label");
|
||||
if (priceInputLabel) {
|
||||
priceInputLabel.textContent =
|
||||
kind === "image"
|
||||
? "Price per image ($)"
|
||||
: "Price input / 1M ($) (embed/vision)";
|
||||
}
|
||||
this.root.querySelectorAll("[data-kind-field]").forEach((field) => {
|
||||
const kinds = (field.dataset.kindField || "").split(/\s+/).filter(Boolean);
|
||||
field.style.display = kinds.includes(kind) ? "" : "none";
|
||||
});
|
||||
this.fillFallbackSelect(this.modelForm.source_model.value);
|
||||
}
|
||||
|
||||
fillFallbackSelect(excludeSource) {
|
||||
const select = this.modelForm.fallback_model;
|
||||
if (!select) return;
|
||||
const kind = this.modelForm.kind.value || "chat";
|
||||
const current = select.value;
|
||||
const options = [`<option value="">(none)</option>`].concat(
|
||||
this.models
|
||||
.filter((m) => m.kind === kind && m.source_model !== excludeSource)
|
||||
.map(
|
||||
(m) =>
|
||||
`<option value="${this.attr(m.source_model)}">${this.escape(m.source_model)}</option>`
|
||||
)
|
||||
);
|
||||
select.innerHTML = options.join("");
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.providerForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.saveProvider();
|
||||
});
|
||||
this.modelForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.saveModel();
|
||||
});
|
||||
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) {
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
}
|
||||
|
||||
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, ${quotaCount} quota rules`;
|
||||
}
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
attr(value) {
|
||||
return this.escape(value).split('"').join(""");
|
||||
}
|
||||
|
||||
async loadProviders() {
|
||||
const data = await Http.getJson("/admin/gateway/providers");
|
||||
this.providers = data.providers || [];
|
||||
this.renderDefault(data.default || {});
|
||||
this.renderProviders();
|
||||
this.fillProviderSelects();
|
||||
return this.providers.length;
|
||||
}
|
||||
|
||||
renderDefault(def) {
|
||||
const el = this.root.querySelector("#gw-default");
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<strong>default</strong> (from <a href="/admin/services">Services config</a>):
|
||||
chat <code class="gw-code">${this.escape(def.model)}</code> at <code class="gw-code">${this.escape(def.base_url)}</code>,
|
||||
embed <code class="gw-code">${this.escape(def.embed_model)}</code>,
|
||||
image <code class="gw-code">${this.escape(def.image_model)}</code>,
|
||||
vision <code class="gw-code">${this.escape(def.vision_model)}</code>`;
|
||||
}
|
||||
|
||||
renderProviders() {
|
||||
if (!this.providers.length) {
|
||||
this.providersBody.innerHTML = `<tr><td colspan="4" class="admin-empty">No extra providers. Model routes with a blank provider use the default.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this.providersBody.innerHTML = this.providers
|
||||
.map(
|
||||
(p) => `<tr>
|
||||
<td>${this.escape(p.name)}</td>
|
||||
<td><code class="gw-code">${this.escape(p.base_url)}</code></td>
|
||||
<td>${p.is_active ? "yes" : "no"}</td>
|
||||
<td class="gw-actions">
|
||||
<button class="admin-btn admin-btn-sm" data-edit-provider="${this.attr(p.name)}">Edit</button>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-provider="${this.attr(p.name)}">Delete</button>
|
||||
</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
fillProviderSelects() {
|
||||
const options =
|
||||
`<option value="">default</option>` +
|
||||
this.providers.map((p) => `<option value="${this.attr(p.name)}">${this.escape(p.name)}</option>`).join("");
|
||||
this.providerSelects.forEach((select) => {
|
||||
const current = select.value;
|
||||
select.innerHTML = options;
|
||||
select.value = current;
|
||||
});
|
||||
}
|
||||
|
||||
async loadModels() {
|
||||
const data = await Http.getJson("/admin/gateway/models");
|
||||
const models = data.models || [];
|
||||
this.models = models;
|
||||
this.fillFallbackSelect(this.modelForm.source_model.value);
|
||||
if (!models.length) {
|
||||
this.modelsBody.innerHTML = `<tr><td colspan="7" class="admin-empty">No model routes. Requests fall through to the default upstream.</td></tr>`;
|
||||
return 0;
|
||||
}
|
||||
this.modelsBody.innerHTML = models
|
||||
.map((m) => {
|
||||
const vision = m.vision_model
|
||||
? `<code class="gw-code">${this.escape(m.vision_provider || m.provider || "default")}/${this.escape(m.vision_model)}</code>`
|
||||
: `<span class="gw-muted">-</span>`;
|
||||
const provider = m.provider || "default";
|
||||
const badges = [];
|
||||
if (m.context_tier_threshold_tokens) {
|
||||
badges.push(`<span class="gw-badge" title="Tier-2 rates above ${m.context_tier_threshold_tokens} input tokens">tiered</span>`);
|
||||
}
|
||||
if (m.off_peak_start_minute !== null && m.off_peak_start_minute !== undefined) {
|
||||
const start = this.minutesToTime(m.off_peak_start_minute);
|
||||
const end = this.minutesToTime(m.off_peak_end_minute);
|
||||
badges.push(`<span class="gw-badge" title="${m.off_peak_discount_pct}% off ${start}-${end} UTC">off-peak</span>`);
|
||||
}
|
||||
if (m.kind === "image" && m.price_input_per_m) {
|
||||
badges.push(`<span class="gw-badge" title="Flat price per generated image">$${m.price_input_per_m}/img</span>`);
|
||||
}
|
||||
if (m.fallback_model) {
|
||||
badges.push(`<span class="gw-badge" title="Falls back to ${this.escape(m.fallback_model)} on failure">fallback: ${this.escape(m.fallback_model)}</span>`);
|
||||
}
|
||||
const economy = badges.length ? badges.join(" ") : `<span class="gw-muted">-</span>`;
|
||||
return `<tr>
|
||||
<td>${this.escape(m.source_model)}</td>
|
||||
<td>${this.escape(provider)}</td>
|
||||
<td><code class="gw-code">${this.escape(m.target_model)}</code></td>
|
||||
<td>${this.escape(m.kind)}</td>
|
||||
<td>${vision}</td>
|
||||
<td>${economy}</td>
|
||||
<td class="gw-actions">
|
||||
<button class="admin-btn admin-btn-sm" data-edit-model='${this.attr(JSON.stringify(m))}'>Edit</button>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-model="${this.attr(m.source_model)}">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return models.length;
|
||||
}
|
||||
|
||||
formValues(form) {
|
||||
const values = {};
|
||||
new FormData(form).forEach((value, key) => {
|
||||
values[key] = value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
timeToMinutes(value) {
|
||||
if (!value) return null;
|
||||
const [hours, minutes] = value.split(":").map((part) => parseInt(part, 10));
|
||||
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
minutesToTime(minutes) {
|
||||
if (minutes === null || minutes === undefined || minutes === "") return "";
|
||||
const total = parseInt(minutes, 10);
|
||||
if (Number.isNaN(total)) return "";
|
||||
const hours = Math.floor(total / 60) % 24;
|
||||
const mins = total % 60;
|
||||
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
optionalFloat(value) {
|
||||
if (value === "" || value === null || value === undefined) return null;
|
||||
const parsed = parseFloat(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
async saveProvider() {
|
||||
const values = this.formValues(this.providerForm);
|
||||
const payload = {
|
||||
name: values.name,
|
||||
base_url: values.base_url,
|
||||
api_key: values.api_key,
|
||||
is_active: values.is_active === "1",
|
||||
};
|
||||
try {
|
||||
await Http.postJson("/admin/gateway/providers", payload);
|
||||
this.providerForm.reset();
|
||||
this.notify("Provider saved", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Save failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async saveModel() {
|
||||
const values = this.formValues(this.modelForm);
|
||||
const payload = {
|
||||
source_model: values.source_model,
|
||||
provider: values.provider,
|
||||
target_model: values.target_model,
|
||||
kind: values.kind,
|
||||
vision_provider: values.vision_provider,
|
||||
vision_model: values.vision_model,
|
||||
context_window: parseInt(values.context_window, 10) || 0,
|
||||
price_cache_hit_per_m: parseFloat(values.price_cache_hit_per_m) || 0,
|
||||
price_cache_miss_per_m: parseFloat(values.price_cache_miss_per_m) || 0,
|
||||
price_output_per_m: parseFloat(values.price_output_per_m) || 0,
|
||||
price_input_per_m: parseFloat(values.price_input_per_m) || 0,
|
||||
context_tier_threshold_tokens: parseInt(values.context_tier_threshold_tokens, 10) || 0,
|
||||
price_cache_hit_per_m_tier2: this.optionalFloat(values.price_cache_hit_per_m_tier2),
|
||||
price_cache_miss_per_m_tier2: this.optionalFloat(values.price_cache_miss_per_m_tier2),
|
||||
price_output_per_m_tier2: this.optionalFloat(values.price_output_per_m_tier2),
|
||||
price_input_per_m_tier2: this.optionalFloat(values.price_input_per_m_tier2),
|
||||
off_peak_start_minute: this.timeToMinutes(values.off_peak_start),
|
||||
off_peak_end_minute: this.timeToMinutes(values.off_peak_end),
|
||||
off_peak_discount_pct: parseFloat(values.off_peak_discount_pct) || 0,
|
||||
fallback_model: values.fallback_model || "",
|
||||
is_active: values.is_active === "1",
|
||||
};
|
||||
try {
|
||||
await Http.postJson("/admin/gateway/models", payload);
|
||||
this.modelForm.reset();
|
||||
this.notify("Model route saved", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Save failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
onProviderClick(event) {
|
||||
const editName = event.target.dataset.editProvider;
|
||||
const delName = event.target.dataset.delProvider;
|
||||
if (editName) {
|
||||
const provider = this.providers.find((p) => p.name === editName);
|
||||
if (provider) this.fillProviderForm(provider);
|
||||
}
|
||||
if (delName) this.deleteProvider(delName);
|
||||
}
|
||||
|
||||
fillProviderForm(provider) {
|
||||
const form = this.providerForm;
|
||||
form.name.value = provider.name;
|
||||
form.base_url.value = provider.base_url || "";
|
||||
form.api_key.value = provider.api_key || "";
|
||||
form.is_active.value = provider.is_active ? "1" : "0";
|
||||
form.name.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
async confirmAction(message, confirmLabel = "Delete") {
|
||||
if (window.app && window.app.dialog) {
|
||||
return window.app.dialog.confirm({ message, danger: true, confirmLabel });
|
||||
}
|
||||
return window.confirm(message);
|
||||
}
|
||||
|
||||
async remove(url) {
|
||||
await Http.sendDelete(url, { silent: true });
|
||||
}
|
||||
|
||||
async deleteProvider(name) {
|
||||
if (!(await this.confirmAction(`Delete provider "${name}"?`))) return;
|
||||
try {
|
||||
await this.remove(`/admin/gateway/providers/${encodeURIComponent(name)}`);
|
||||
this.notify("Provider deleted", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Delete failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
onModelClick(event) {
|
||||
const editRaw = event.target.dataset.editModel;
|
||||
const delSource = event.target.dataset.delModel;
|
||||
if (editRaw) this.fillModelForm(JSON.parse(editRaw));
|
||||
if (delSource) this.deleteModel(delSource);
|
||||
}
|
||||
|
||||
fillModelForm(model) {
|
||||
const form = this.modelForm;
|
||||
form.source_model.value = model.source_model || "";
|
||||
form.provider.value = model.provider || "";
|
||||
form.target_model.value = model.target_model || "";
|
||||
form.kind.value = model.kind || "chat";
|
||||
this.syncKindFields();
|
||||
form.vision_provider.value = model.vision_provider || "";
|
||||
form.vision_model.value = model.vision_model || "";
|
||||
form.context_window.value = model.context_window || 0;
|
||||
form.price_cache_hit_per_m.value = model.price_cache_hit_per_m || 0;
|
||||
form.price_cache_miss_per_m.value = model.price_cache_miss_per_m || 0;
|
||||
form.price_output_per_m.value = model.price_output_per_m || 0;
|
||||
form.price_input_per_m.value = model.price_input_per_m || 0;
|
||||
form.context_tier_threshold_tokens.value = model.context_tier_threshold_tokens || 0;
|
||||
form.price_cache_hit_per_m_tier2.value = model.price_cache_hit_per_m_tier2 ?? "";
|
||||
form.price_cache_miss_per_m_tier2.value = model.price_cache_miss_per_m_tier2 ?? "";
|
||||
form.price_output_per_m_tier2.value = model.price_output_per_m_tier2 ?? "";
|
||||
form.price_input_per_m_tier2.value = model.price_input_per_m_tier2 ?? "";
|
||||
form.off_peak_start.value = this.minutesToTime(model.off_peak_start_minute);
|
||||
form.off_peak_end.value = this.minutesToTime(model.off_peak_end_minute);
|
||||
form.off_peak_discount_pct.value = model.off_peak_discount_pct || 0;
|
||||
form.fallback_model.value = model.fallback_model || "";
|
||||
form.is_active.value = model.is_active ? "1" : "0";
|
||||
form.source_model.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
async deleteModel(source) {
|
||||
if (!(await this.confirmAction(`Delete model route "${source}"?`))) return;
|
||||
try {
|
||||
await this.remove(`/admin/gateway/models/${encodeURIComponent(source)}`);
|
||||
this.notify("Model route deleted", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
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" data-reset-quota='${this.attr(JSON.stringify(r))}'>Reset spend</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 resetRaw = event.target.dataset.resetQuota;
|
||||
const delUid = event.target.dataset.delQuota;
|
||||
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
|
||||
if (resetRaw) this.resetQuotaSpend(JSON.parse(resetRaw));
|
||||
if (delUid) this.deleteQuotaRule(delUid);
|
||||
}
|
||||
|
||||
async resetQuotaSpend(rule) {
|
||||
const label = this.scopeLabel(rule);
|
||||
if (!(await this.confirmAction(`Reset the counted 24h spend for ${label}? The usage history is kept.`, "Reset"))) return;
|
||||
try {
|
||||
await Http.postJson("/admin/gateway/quota-resets", {
|
||||
owner_kind: rule.owner_kind || "",
|
||||
owner_id: rule.owner_id || "",
|
||||
app_reference: rule.app_reference || "",
|
||||
});
|
||||
this.notify("Quota spend reset", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Reset failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async deleteQuotaRule(uid) {
|
||||
if (!(await this.confirmAction("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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
function themeColor(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function statusCodeColor(code, colors) {
|
||||
const value = Number(code);
|
||||
if (value >= 200 && value < 300) return colors.success;
|
||||
if (value >= 400 && value < 500) return colors.warning;
|
||||
if (value >= 500) return colors.danger;
|
||||
return colors.secondary;
|
||||
}
|
||||
|
||||
export class GatewayChartManager {
|
||||
constructor() {
|
||||
this.charts = {};
|
||||
this.colors = {
|
||||
success: themeColor("--success", "#2f6f4f"),
|
||||
danger: themeColor("--danger", "#a4423a"),
|
||||
accent: themeColor("--accent", "#2b4c7e"),
|
||||
secondary: themeColor("--text-muted", "#6a7f93"),
|
||||
warning: themeColor("--warning", "#c07c2c"),
|
||||
grid: themeColor("--border", "rgba(120, 130, 140, 0.15)"),
|
||||
text: themeColor("--text-primary", "#3a3f44"),
|
||||
};
|
||||
this.palette = [
|
||||
this.colors.accent,
|
||||
this.colors.warning,
|
||||
this.colors.secondary,
|
||||
this.colors.success,
|
||||
"#3f8f8f",
|
||||
"#8f5b3f",
|
||||
"#5b6ecf",
|
||||
this.colors.danger,
|
||||
];
|
||||
Chart.defaults.color = this.colors.text;
|
||||
Chart.defaults.font.family = "inherit";
|
||||
Chart.defaults.plugins.legend.labels.boxWidth = 12;
|
||||
}
|
||||
|
||||
statusCodeColor(code) {
|
||||
return statusCodeColor(code, this.colors);
|
||||
}
|
||||
|
||||
_ensureChart(canvasId, config) {
|
||||
const existing = this.charts[canvasId];
|
||||
if (existing) existing.destroy();
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return null;
|
||||
this.charts[canvasId] = new Chart(canvas, config);
|
||||
return this.charts[canvasId];
|
||||
}
|
||||
|
||||
renderTimeseries(canvasId, timeseries) {
|
||||
const labels = timeseries.map((row) => new Date(row.bucket_start * 1000).toLocaleString());
|
||||
const successData = timeseries.map((row) => row.success || 0);
|
||||
const errorData = timeseries.map((row) => (row.requests || 0) - (row.success || 0));
|
||||
this._ensureChart(canvasId, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: "Successful", data: successData, borderColor: this.colors.success, backgroundColor: "transparent", tension: 0.25, pointRadius: 0 },
|
||||
{ label: "Failed", data: errorData, borderColor: this.colors.danger, backgroundColor: "transparent", tension: 0.25, pointRadius: 0 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
scales: {
|
||||
x: { grid: { color: this.colors.grid }, ticks: { maxTicksLimit: 10 } },
|
||||
y: { grid: { color: this.colors.grid }, beginAtZero: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renderTotals(canvasId, totals) {
|
||||
const success = totals.success || 0;
|
||||
const error = totals.error || 0;
|
||||
this._ensureChart(canvasId, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: ["Successful", "Failed"],
|
||||
datasets: [{ data: [success, error], backgroundColor: [this.colors.success, this.colors.danger] }],
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false },
|
||||
});
|
||||
}
|
||||
|
||||
renderPerModelBar(canvasId, perModel, valueKey, label) {
|
||||
const top = [...perModel].sort((a, b) => (b[valueKey] || 0) - (a[valueKey] || 0)).slice(0, 10);
|
||||
this._ensureChart(canvasId, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: top.map((row) => `${row.model} (${row.provider})`),
|
||||
datasets: [{ label, data: top.map((row) => row[valueKey] || 0), backgroundColor: this.palette }],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: this.colors.grid }, beginAtZero: true },
|
||||
y: { grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renderEndpointBreakdown(canvasId, perEndpoint) {
|
||||
this._ensureChart(canvasId, {
|
||||
type: "pie",
|
||||
data: {
|
||||
labels: perEndpoint.map((row) => row.endpoint),
|
||||
datasets: [{ data: perEndpoint.map((row) => row.total_requests), backgroundColor: this.palette }],
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false },
|
||||
});
|
||||
}
|
||||
|
||||
renderBucketBar(canvasId, rows, labelKey, valueKey, label, colorFn) {
|
||||
const labels = rows.map((row) => String(row[labelKey]));
|
||||
const data = rows.map((row) => row[valueKey] || 0);
|
||||
const backgroundColor = colorFn ? labels.map((l) => colorFn(l)) : this.palette;
|
||||
this._ensureChart(canvasId, {
|
||||
type: "bar",
|
||||
data: { labels, datasets: [{ label, data, backgroundColor }] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: { grid: { color: this.colors.grid }, beginAtZero: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renderHorizontalBar(canvasId, labels, data, label) {
|
||||
this._ensureChart(canvasId, {
|
||||
type: "bar",
|
||||
data: { labels, datasets: [{ label, data, backgroundColor: this.palette }] },
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: this.colors.grid }, beginAtZero: true },
|
||||
y: { grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renderDoughnutFromCounts(canvasId, labels, data, colors) {
|
||||
this._ensureChart(canvasId, {
|
||||
type: "doughnut",
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors || this.palette }] },
|
||||
options: { responsive: true, maintainAspectRatio: false },
|
||||
});
|
||||
}
|
||||
|
||||
renderStackedBar(canvasId, labels, datasets) {
|
||||
this._ensureChart(canvasId, {
|
||||
type: "bar",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: this.colors.grid }, stacked: true, beginAtZero: true },
|
||||
y: { grid: { display: false }, stacked: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renderScatter(canvasId, points, label, xLabel, yLabel) {
|
||||
this._ensureChart(canvasId, {
|
||||
type: "scatter",
|
||||
data: {
|
||||
datasets: [{
|
||||
label,
|
||||
data: points,
|
||||
backgroundColor: this.colors.accent,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: this.colors.grid }, title: { display: true, text: xLabel } },
|
||||
y: { grid: { color: this.colors.grid }, title: { display: true, text: yLabel } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class GatewayModelForm {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.providerSelect = root.querySelector("#gw-model-provider");
|
||||
this.targetInput = root.querySelector("#gw-model-target");
|
||||
this.targetSelect = root.querySelector("#gw-model-target-select");
|
||||
this.targetHint = root.querySelector("#gw-model-target-hint");
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.providerSelect || !this.targetInput || !this.targetSelect) return;
|
||||
this.providerSelect.addEventListener("change", () => this.refresh(""));
|
||||
if (this.providerSelect.value) {
|
||||
this.refresh(this.targetInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(keepValue) {
|
||||
const provider = this.providerSelect.value;
|
||||
let models = null;
|
||||
try {
|
||||
const data = await Http.getJson(
|
||||
`/admin/gateway/provider-models?provider=${encodeURIComponent(provider)}`
|
||||
);
|
||||
models = Array.isArray(data.models) ? data.models : null;
|
||||
} catch (err) {
|
||||
models = null;
|
||||
}
|
||||
if (models && models.length) {
|
||||
this.showSelect(models, keepValue);
|
||||
} else {
|
||||
this.showInput(keepValue);
|
||||
}
|
||||
}
|
||||
|
||||
showSelect(models, keepValue) {
|
||||
const options = models.slice();
|
||||
if (keepValue && !options.includes(keepValue)) {
|
||||
options.unshift(keepValue);
|
||||
}
|
||||
this.targetSelect.innerHTML = options
|
||||
.map((model) => {
|
||||
const selected = model === keepValue ? "selected" : "";
|
||||
return `<option value="${this.attr(model)}" ${selected}>${this.escape(model)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
this.targetSelect.hidden = false;
|
||||
this.targetSelect.disabled = false;
|
||||
this.targetInput.hidden = true;
|
||||
this.targetInput.disabled = true;
|
||||
if (this.targetHint) this.targetHint.hidden = false;
|
||||
}
|
||||
|
||||
showInput(keepValue) {
|
||||
if (keepValue) this.targetInput.value = keepValue;
|
||||
this.targetInput.hidden = false;
|
||||
this.targetInput.disabled = false;
|
||||
this.targetSelect.hidden = true;
|
||||
this.targetSelect.disabled = true;
|
||||
this.targetSelect.innerHTML = "";
|
||||
if (this.targetHint) this.targetHint.hidden = true;
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
attr(value) {
|
||||
return this.escape(value).split('"').join(""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { GatewayChartManager } from "./GatewayChartManager.js";
|
||||
|
||||
const AUTO_REFRESH_MS = 30000;
|
||||
|
||||
function formatMs(value) {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${Math.round(value)}ms`;
|
||||
}
|
||||
|
||||
function formatPct(success, total) {
|
||||
if (!total) return "-";
|
||||
return `${((success / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export class GatewayStats {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.charts = new GatewayChartManager();
|
||||
this.rangeSelect = root.querySelector("#gw-stats-range");
|
||||
this.modelSelect = root.querySelector("#gw-model-detail-select");
|
||||
this.modelEmptyState = root.querySelector("#gw-model-detail-empty-state");
|
||||
this.modelCharts = root.querySelector("#gw-model-detail-charts");
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.rangeSelect) return;
|
||||
this.rangeSelect.addEventListener("change", () => this.refresh());
|
||||
if (this.modelSelect) {
|
||||
this.modelSelect.addEventListener("change", () => this.refreshModelDetail());
|
||||
}
|
||||
this.loadModelOptions();
|
||||
this.refresh();
|
||||
this.timer = window.setInterval(() => this.refresh(), AUTO_REFRESH_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) window.clearInterval(this.timer);
|
||||
}
|
||||
|
||||
get range() {
|
||||
return this.rangeSelect ? this.rangeSelect.value : "24h";
|
||||
}
|
||||
|
||||
async loadModelOptions() {
|
||||
let data;
|
||||
try {
|
||||
data = await Http.getJson("/admin/gateway/stats/models");
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
if (!this.modelSelect) return;
|
||||
for (const entry of data.models || []) {
|
||||
const option = document.createElement("option");
|
||||
option.value = `${entry.provider}|${entry.model}`;
|
||||
option.textContent = `${entry.model} (${entry.provider})`;
|
||||
this.modelSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
let data;
|
||||
try {
|
||||
data = await Http.getJson(`/admin/gateway/stats/data?range=${encodeURIComponent(this.range)}`);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
this.renderTiles(data);
|
||||
this.renderCharts(data);
|
||||
this.renderModelTable(data.per_model || []);
|
||||
this.renderFailuresTable(data.recent_failures || []);
|
||||
}
|
||||
|
||||
renderTiles(data) {
|
||||
const totalRequests = (data.totals.success || 0) + (data.totals.error || 0);
|
||||
this.setText("#gw-stat-total-requests", totalRequests);
|
||||
this.setText("#gw-stat-success-rate", formatPct(data.totals.success || 0, totalRequests));
|
||||
this.setText("#gw-stat-models-tracked", (data.per_model || []).length);
|
||||
this.setText(
|
||||
"#gw-stat-generated-at",
|
||||
data.generated_at ? new Date(data.generated_at * 1000).toLocaleTimeString() : "-"
|
||||
);
|
||||
}
|
||||
|
||||
setText(selector, value) {
|
||||
const el = this.root.querySelector(selector);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
renderCharts(data) {
|
||||
this.charts.renderTimeseries("gw-chart-timeseries", data.timeseries || []);
|
||||
this.charts.renderTotals("gw-chart-totals", data.totals || {});
|
||||
this.charts.renderPerModelBar("gw-chart-per-model", data.per_model || [], "total_requests", "Requests");
|
||||
this.charts.renderEndpointBreakdown("gw-chart-endpoints", data.per_endpoint || []);
|
||||
this.charts.renderBucketBar(
|
||||
"gw-chart-status-codes",
|
||||
data.status_codes || [],
|
||||
"status_code",
|
||||
"count",
|
||||
"Requests",
|
||||
(code) => this.charts.statusCodeColor(code)
|
||||
);
|
||||
this.charts.renderDoughnutFromCounts(
|
||||
"gw-chart-streaming",
|
||||
["Streamed", "Non-streamed"],
|
||||
[data.streaming_split?.streamed || 0, data.streaming_split?.non_streamed || 0]
|
||||
);
|
||||
this.charts.renderHorizontalBar(
|
||||
"gw-chart-failure-reasons",
|
||||
(data.failure_reasons || []).map((r) => r.reason || "unknown"),
|
||||
(data.failure_reasons || []).map((r) => r.count),
|
||||
"Failures"
|
||||
);
|
||||
this.charts.renderBucketBar(
|
||||
"gw-chart-hourly",
|
||||
data.hourly_distribution || [],
|
||||
"hour",
|
||||
"count",
|
||||
"Requests"
|
||||
);
|
||||
this.charts.renderBucketBar(
|
||||
"gw-chart-latency-hist",
|
||||
data.latency_histogram || [],
|
||||
"label",
|
||||
"count",
|
||||
"Requests"
|
||||
);
|
||||
this.charts.renderBucketBar(
|
||||
"gw-chart-tps-hist",
|
||||
data.tokens_per_second_histogram || [],
|
||||
"label",
|
||||
"count",
|
||||
"Requests"
|
||||
);
|
||||
const withWeight = (data.per_model || []).map((row) => ({
|
||||
...row,
|
||||
weight: row.health ? row.health.weight : null,
|
||||
}));
|
||||
this.charts.renderPerModelBar("gw-chart-weight", withWeight, "weight", "Weight");
|
||||
this.charts.renderStackedBar(
|
||||
"gw-chart-tokens",
|
||||
(data.per_model || []).slice(0, 10).map((row) => `${row.model} (${row.provider})`),
|
||||
[
|
||||
{
|
||||
label: "Prompt",
|
||||
data: (data.per_model || []).slice(0, 10).map((row) => row.prompt_tokens || 0),
|
||||
backgroundColor: this.charts.colors.accent,
|
||||
},
|
||||
{
|
||||
label: "Completion",
|
||||
data: (data.per_model || []).slice(0, 10).map((row) => row.completion_tokens || 0),
|
||||
backgroundColor: this.charts.colors.warning,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
renderModelTable(perModel) {
|
||||
const body = this.root.querySelector("#gw-stats-model-table-body");
|
||||
if (!body) return;
|
||||
if (!perModel.length) {
|
||||
body.innerHTML = '<tr><td colspan="10" class="admin-empty">No requests in this range.</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
for (const row of perModel) {
|
||||
const health = row.health || {};
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${row.model}</td>
|
||||
<td>${row.provider}</td>
|
||||
<td>${row.total_requests}</td>
|
||||
<td>${formatPct(row.success_requests, row.total_requests)}</td>
|
||||
<td>${formatMs(row.avg_latency_ms)}</td>
|
||||
<td>${row.avg_tokens_per_second ?? "-"}</td>
|
||||
<td>${health.weight ?? "-"}</td>
|
||||
<td>${health.circuit_open ? "open" : "closed"}</td>
|
||||
<td>${row.prompt_tokens}</td>
|
||||
<td>${row.completion_tokens}</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
renderFailuresTable(failures) {
|
||||
const body = this.root.querySelector("#gw-stats-failures-table-body");
|
||||
if (!body) return;
|
||||
if (!failures.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" class="admin-empty">No failures in this range.</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
for (const row of failures) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${row.created_at ? new Date(row.created_at).toLocaleString() : "-"}</td>
|
||||
<td>${row.model || "-"}</td>
|
||||
<td>${row.provider || "-"}</td>
|
||||
<td>${row.endpoint || "-"}</td>
|
||||
<td>${row.status_code ?? "-"}</td>
|
||||
<td>${row.reason || "-"}</td>
|
||||
<td>${row.fallback_used_route || "no fallback"}</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshModelDetail() {
|
||||
const raw = this.modelSelect ? this.modelSelect.value : "";
|
||||
if (!raw) {
|
||||
if (this.modelEmptyState) this.modelEmptyState.hidden = false;
|
||||
if (this.modelCharts) this.modelCharts.hidden = true;
|
||||
return;
|
||||
}
|
||||
const [provider, model] = raw.split("|");
|
||||
let data;
|
||||
try {
|
||||
data = await Http.getJson(
|
||||
`/admin/gateway/stats/model/${encodeURIComponent(provider)}/${encodeURIComponent(model)}?range=${encodeURIComponent(this.range)}`
|
||||
);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
if (this.modelEmptyState) this.modelEmptyState.hidden = true;
|
||||
if (this.modelCharts) this.modelCharts.hidden = false;
|
||||
const summary = data.summary || {};
|
||||
this.setText("#gw-model-detail-requests", summary.total_requests ?? "-");
|
||||
this.setText(
|
||||
"#gw-model-detail-success-rate",
|
||||
formatPct(summary.success_requests || 0, summary.total_requests || 0)
|
||||
);
|
||||
this.setText("#gw-model-detail-latency", formatMs(summary.avg_latency_ms));
|
||||
this.setText("#gw-model-detail-tps", summary.avg_tokens_per_second ?? "-");
|
||||
this.charts.renderTimeseries("gw-chart-model-timeseries", data.timeseries || []);
|
||||
this.charts.renderBucketBar(
|
||||
"gw-chart-model-latency-hist",
|
||||
data.latency_histogram || [],
|
||||
"label",
|
||||
"count",
|
||||
"Requests"
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+14
File diff suppressed because one or more lines are too long
@@ -4,125 +4,256 @@
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gateway.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div id="gateway-admin">
|
||||
<div class="admin-toolbar">
|
||||
<h2>Gateway routing</h2>
|
||||
<span class="admin-count" id="gw-count" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<p class="gw-intro">Map any requested model name onto a provider and target model, each with its own pricing economy and an optional vision model for image to text merging. Image routes use a flat per-image price. Unmapped requests fall through to the default upstream unchanged.</p>
|
||||
|
||||
<section class="gw-section">
|
||||
<div class="gw-section-head">
|
||||
<h3>Providers</h3>
|
||||
</div>
|
||||
<p class="gw-section-hint">Named upstreams reused across model routes. A model route with a blank provider uses the default below.</p>
|
||||
<div class="gw-default" id="gw-default" role="status" aria-live="polite"></div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<caption class="sr-only">Providers</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Name</th><th scope="col">Base URL</th><th scope="col">Active</th><th scope="col" class="gw-actions">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="gw-providers"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form class="gw-form" id="gw-provider-form" autocomplete="off" role="group" aria-label="Add or update provider">
|
||||
<p class="gw-form-title">Add or update provider</p>
|
||||
<div class="gw-field"><label for="gw-provider-name">Name</label><input type="text" id="gw-provider-name" name="name" placeholder="openrouter" required aria-required="true"></div>
|
||||
<div class="gw-field"><label for="gw-provider-base-url">Base URL (chat completions)</label><input type="url" id="gw-provider-base-url" name="base_url" placeholder="https://openrouter.ai/api/v1/chat/completions"></div>
|
||||
<div class="gw-field"><label for="gw-provider-api-key">API key</label><input type="password" id="gw-provider-api-key" name="api_key" placeholder="sk-..." autocomplete="new-password"></div>
|
||||
<div class="gw-field"><label for="gw-provider-active">Active</label><select id="gw-provider-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
|
||||
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save provider</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="gw-section">
|
||||
<div class="gw-section-head">
|
||||
<h3>Model routes</h3>
|
||||
</div>
|
||||
<p class="gw-section-hint">Each source model maps to a provider and target model with its own economy. Chat uses cache-hit, cache-miss and output prices (USD per 1M tokens); embeddings use the input price; image routes use the input price as a flat USD per image; a vision model adds input and output pricing for the image description merge. An optional fallback model is retried once, automatically, whenever this route fails after its own retries are exhausted - pick any other already-configured model of the same kind.</p>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<caption class="sr-only">Model routes</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Source</th><th scope="col">Provider</th><th scope="col">Target</th><th scope="col">Kind</th><th scope="col">Vision</th><th scope="col">Economy</th><th scope="col" class="gw-actions">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="gw-models"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form class="gw-form" id="gw-model-form" autocomplete="off" role="group" aria-label="Add or update model route">
|
||||
<p class="gw-form-title">Add or update model route</p>
|
||||
<div class="gw-field"><label for="gw-model-source">Source model (requested)</label><input type="text" id="gw-model-source" name="source_model" placeholder="gpt-4o" required aria-required="true"></div>
|
||||
<div class="gw-field"><label for="gw-model-provider">Provider</label><select id="gw-model-provider" name="provider" data-provider-select></select></div>
|
||||
<div class="gw-field"><label for="gw-model-target">Target model (upstream)</label><input type="text" id="gw-model-target" name="target_model" placeholder="openai/gpt-4o" required aria-required="true"></div>
|
||||
<div class="gw-field"><label for="gw-model-kind">Kind</label><select id="gw-model-kind" name="kind"><option value="chat">chat</option><option value="embed">embed</option><option value="image">image</option></select></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-vision-provider">Vision provider</label><select id="gw-model-vision-provider" name="vision_provider" data-provider-select></select></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-vision-model">Vision model</label><input type="text" id="gw-model-vision-model" name="vision_model" placeholder="(optional) google/gemma-3-12b-it"></div>
|
||||
<div class="gw-field"><label for="gw-model-context">Context window</label><input type="number" id="gw-model-context" name="context_window" min="0" value="0"></div>
|
||||
<div class="gw-field"><label for="gw-model-active">Active</label><select id="gw-model-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
|
||||
<div class="gw-field"><label for="gw-model-fallback">Fallback model</label><select id="gw-model-fallback" name="fallback_model" title="Tried automatically when this model fails after retries. Only other models of the same kind are offered."><option value="">(none)</option></select></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-hit">Price cache-hit / 1M ($)</label><input type="number" id="gw-model-price-cache-hit" name="price_cache_hit_per_m" min="0" step="0.0001" value="0"></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-miss">Price cache-miss / 1M ($)</label><input type="number" id="gw-model-price-cache-miss" name="price_cache_miss_per_m" min="0" step="0.0001" value="0"></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-output">Price output / 1M ($)</label><input type="number" id="gw-model-price-output" name="price_output_per_m" min="0" step="0.0001" value="0"></div>
|
||||
<div class="gw-field" data-kind-field="embed chat"><label for="gw-model-price-input" id="gw-model-price-input-label">Price input / 1M ($) (embed/vision)</label><input type="number" id="gw-model-price-input" name="price_input_per_m" min="0" step="0.0001" value="0"></div>
|
||||
<p class="gw-form-title gw-form-subtitle" data-kind-field="chat embed">Tiered / off-peak pricing (optional)</p>
|
||||
<p class="gw-section-hint" data-kind-field="chat embed">Some providers charge a different rate once a request crosses a context-length threshold, or discount a fixed time-of-day window. Leave blank/zero to keep the flat rates above at all times.</p>
|
||||
<div class="gw-field" data-kind-field="chat embed"><label for="gw-model-tier-threshold">Tier-2 threshold (input tokens)</label><input type="number" id="gw-model-tier-threshold" name="context_tier_threshold_tokens" min="0" value="0" title="0 disables tiered pricing"></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-hit-tier2">Tier-2 price cache-hit / 1M ($)</label><input type="number" id="gw-model-price-cache-hit-tier2" name="price_cache_hit_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-miss-tier2">Tier-2 price cache-miss / 1M ($)</label><input type="number" id="gw-model-price-cache-miss-tier2" name="price_cache_miss_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
|
||||
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-output-tier2">Tier-2 price output / 1M ($)</label><input type="number" id="gw-model-price-output-tier2" name="price_output_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
|
||||
<div class="gw-field" data-kind-field="chat embed"><label for="gw-model-price-input-tier2">Tier-2 price input / 1M ($) (embed/vision)</label><input type="number" id="gw-model-price-input-tier2" name="price_input_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
|
||||
<div class="gw-field" data-kind-field="chat embed image"><label for="gw-model-off-peak-start">Off-peak start (UTC)</label><input type="time" id="gw-model-off-peak-start" name="off_peak_start" title="Leave blank to disable off-peak discount"></div>
|
||||
<div class="gw-field" data-kind-field="chat embed image"><label for="gw-model-off-peak-end">Off-peak end (UTC)</label><input type="time" id="gw-model-off-peak-end" name="off_peak_end" title="Leave blank to disable off-peak discount"></div>
|
||||
<div class="gw-field" data-kind-field="chat embed image"><label for="gw-model-off-peak-discount">Off-peak discount (%)</label><input type="number" id="gw-model-off-peak-discount" name="off_peak_discount_pct" min="0" max="100" step="0.1" value="0"></div>
|
||||
<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 class="admin-toolbar">
|
||||
<h2>Gateway routing</h2>
|
||||
<span class="admin-count">{{ providers|length }} providers, {{ models|length }} routes, {{ quota_rules|length }} quota rules</span>
|
||||
</div>
|
||||
<p class="gw-intro">Map any requested model name onto a provider and target model, each with its own pricing economy and an optional vision model for image to text merging. Image routes use a flat per-image price. Unmapped requests fall through to the default upstream unchanged.</p>
|
||||
|
||||
<nav class="admin-tabs" aria-label="Gateway configuration">
|
||||
<a href="/admin/gateway?tab=models" class="admin-tab {% if tab == 'models' %}active{% endif %}">Models <span class="admin-tab-count">{{ models|length }}</span></a>
|
||||
<a href="/admin/gateway?tab=providers" class="admin-tab {% if tab == 'providers' %}active{% endif %}">Providers <span class="admin-tab-count">{{ providers|length }}</span></a>
|
||||
<a href="/admin/gateway?tab=quota" class="admin-tab {% if tab == 'quota' %}active{% endif %}">Quota rules <span class="admin-tab-count">{{ quota_rules|length }}</span></a>
|
||||
<a href="/admin/gateway?tab=stats" class="admin-tab {% if tab == 'stats' %}active{% endif %}">Stats</a>
|
||||
</nav>
|
||||
|
||||
{% if tab == 'models' %}
|
||||
<section class="gw-section">
|
||||
<div class="gw-section-head">
|
||||
<h3>Model routes</h3>
|
||||
<a href="/admin/gateway/models/new" class="admin-btn admin-btn-primary admin-btn-sm">Add model route</a>
|
||||
</div>
|
||||
<p class="gw-section-hint">Each source model maps to a provider and target model with its own economy. Chat uses cache-hit, cache-miss and output prices (USD per 1M tokens); embeddings use the input price; image routes use the input price as a flat USD per image; a vision model adds input and output pricing for the image description merge. An optional fallback model is retried once, automatically, whenever this route fails after its own retries are exhausted.</p>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<caption class="sr-only">Model routes</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Source</th><th scope="col">Provider</th><th scope="col">Target</th><th scope="col">Kind</th><th scope="col">Vision</th><th scope="col">Economy</th><th scope="col" class="gw-actions">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if not models %}
|
||||
<tr><td colspan="7" class="admin-empty">No model routes. Requests fall through to the default upstream.</td></tr>
|
||||
{% endif %}
|
||||
{% for m in models %}
|
||||
<tr>
|
||||
<td>{{ m.source_model }}</td>
|
||||
<td>{{ m.provider or "default" }}</td>
|
||||
<td><code class="gw-code">{{ m.target_model }}</code></td>
|
||||
<td>{{ m.kind }}</td>
|
||||
<td>
|
||||
{% if m.vision_model %}
|
||||
<code class="gw-code">{{ m.vision_provider or m.provider or "default" }}/{{ m.vision_model }}</code>
|
||||
{% else %}
|
||||
<span class="gw-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.context_tier_threshold_tokens %}<span class="gw-badge" title="Tier-2 rates above {{ m.context_tier_threshold_tokens }} input tokens">tiered</span>{% endif %}
|
||||
{% if m.off_peak_start_minute is not none %}<span class="gw-badge" title="{{ m.off_peak_discount_pct }}% off during the configured UTC window">off-peak</span>{% endif %}
|
||||
{% if m.kind == 'image' and m.price_input_per_m %}<span class="gw-badge" title="Flat price per generated image">${{ m.price_input_per_m }}/img</span>{% endif %}
|
||||
{% if m.fallback_model %}<span class="gw-badge" title="Falls back to {{ m.fallback_model }} on failure">fallback: {{ m.fallback_model }}</span>{% endif %}
|
||||
{% if not m.context_tier_threshold_tokens and m.off_peak_start_minute is none and not (m.kind == 'image' and m.price_input_per_m) and not m.fallback_model %}<span class="gw-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="gw-actions">
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway/models/{{ m.source_model }}/edit">Edit</a>
|
||||
<form method="post" action="/admin/gateway/models/{{ m.source_model }}/delete" class="gw-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm admin-btn-danger" data-confirm="Delete model route "{{ m.source_model }}"?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if tab == 'providers' %}
|
||||
<section class="gw-section">
|
||||
<div class="gw-section-head">
|
||||
<h3>Providers</h3>
|
||||
<a href="/admin/gateway/providers/new" class="admin-btn admin-btn-primary admin-btn-sm">Add provider</a>
|
||||
</div>
|
||||
<p class="gw-section-hint">Named upstreams reused across model routes. A model route with a blank provider uses the default below.</p>
|
||||
<div class="gw-default">
|
||||
<strong>default</strong> (from <a href="/admin/services">Services config</a>):
|
||||
chat <code class="gw-code">{{ default_provider.model }}</code> at <code class="gw-code">{{ default_provider.base_url }}</code>,
|
||||
embed <code class="gw-code">{{ default_provider.embed_model }}</code>,
|
||||
image <code class="gw-code">{{ default_provider.image_model }}</code>,
|
||||
vision <code class="gw-code">{{ default_provider.vision_model }}</code>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<caption class="sr-only">Providers</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Name</th><th scope="col">Base URL</th><th scope="col">Active</th><th scope="col">Client profile</th><th scope="col" class="gw-actions">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if not providers %}
|
||||
<tr><td colspan="5" class="admin-empty">No extra providers. Model routes with a blank provider use the default.</td></tr>
|
||||
{% endif %}
|
||||
{% for p in providers %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td><code class="gw-code">{{ p.base_url }}</code></td>
|
||||
<td>{{ "yes" if p.is_active else "no" }}</td>
|
||||
<td>{{ p.client_profile or "generic" }}</td>
|
||||
<td class="gw-actions">
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway/providers/{{ p.name }}/edit">Edit</a>
|
||||
<form method="post" action="/admin/gateway/providers/{{ p.name }}/delete" class="gw-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm admin-btn-danger" data-confirm="Delete provider "{{ p.name }}"?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if tab == 'quota' %}
|
||||
<section class="gw-section">
|
||||
<div class="gw-section-head">
|
||||
<h3>Quota rules</h3>
|
||||
<a href="/admin/gateway/quota-rules/new" class="admin-btn admin-btn-primary admin-btn-sm">Add quota rule</a>
|
||||
</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. With no matching rule, the global defaults below apply per caller. A limit of 0 means unlimited.</p>
|
||||
<div class="gw-default">
|
||||
<strong>global defaults</strong> (from <a href="/admin/services">Services config</a>, apply per caller with no matching rule):
|
||||
member <code class="gw-code">{{ "$%.2f/24h"|format(quota_defaults.user) if quota_defaults.user else "unlimited" }}</code>,
|
||||
admin <code class="gw-code">{{ "$%.2f/24h"|format(quota_defaults.admin) if quota_defaults.admin else "unlimited" }}</code>,
|
||||
guest <code class="gw-code">{{ "$%.2f/24h"|format(quota_defaults.guest) if quota_defaults.guest else "unlimited" }}</code>,
|
||||
internal <code class="gw-code">{{ "$%.2f/24h"|format(quota_defaults.internal) if quota_defaults.internal else "unlimited" }}</code>,
|
||||
access key <code class="gw-code">{{ "$%.2f/24h"|format(quota_defaults.key) if quota_defaults.key else "unlimited" }}</code>
|
||||
</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>
|
||||
{% if not quota_rules %}
|
||||
<tr><td colspan="6" class="admin-empty">No quota rules. Every caller is capped by the global defaults above.</td></tr>
|
||||
{% endif %}
|
||||
{% for r in quota_rules %}
|
||||
<tr>
|
||||
<td><code class="gw-code">role={{ r.owner_kind or "any" }}, user={{ r.owner_id or "any" }}, app={{ r.app_reference or "any" }}</code></td>
|
||||
<td>{{ "$%.2f"|format(r.limit_usd) if r.limit_usd else "unlimited" }}</td>
|
||||
<td>${{ "%.4f"|format(r.spent_24h_usd) }}</td>
|
||||
<td>{{ "yes" if r.is_active else "no" }}</td>
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="gw-actions">
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway/quota-rules/{{ r.uid }}/edit">Edit</a>
|
||||
<form method="post" action="/admin/gateway/quota-rules/{{ r.uid }}/reset" class="gw-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm" data-confirm="Reset the counted 24h spend for this rule? The usage history is kept.">Reset spend</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/gateway/quota-rules/{{ r.uid }}/delete" class="gw-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm admin-btn-danger" data-confirm="Delete this quota rule?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if tab == 'stats' %}
|
||||
<section class="gw-section" id="gw-stats-panel" data-stats-root>
|
||||
<div class="gw-stats-head">
|
||||
<p class="gw-section-hint" style="margin:0;">Request volume, latency, and per-model reliability, computed from the gateway usage ledger you already record - no separate data collection.</p>
|
||||
<select id="gw-stats-range">
|
||||
{% for r in stats_ranges %}
|
||||
<option value="{{ r }}" {% if r == '24h' %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="gw-tiles">
|
||||
<div class="gw-tile"><div class="gw-tile-label">Total requests</div><div class="gw-tile-value" id="gw-stat-total-requests">-</div></div>
|
||||
<div class="gw-tile"><div class="gw-tile-label">Success rate</div><div class="gw-tile-value" id="gw-stat-success-rate">-</div></div>
|
||||
<div class="gw-tile"><div class="gw-tile-label">Models tracked</div><div class="gw-tile-value" id="gw-stat-models-tracked">-</div></div>
|
||||
<div class="gw-tile"><div class="gw-tile-label">Last updated</div><div class="gw-tile-value" id="gw-stat-generated-at">-</div></div>
|
||||
</div>
|
||||
|
||||
<div class="gw-charts-grid">
|
||||
<div class="gw-panel gw-wide"><h4>Requests over time</h4><div class="gw-chart-box"><canvas id="gw-chart-timeseries"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Success / failure</h4><div class="gw-chart-box"><canvas id="gw-chart-totals"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Requests by model</h4><div class="gw-chart-box"><canvas id="gw-chart-per-model"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Requests by endpoint</h4><div class="gw-chart-box"><canvas id="gw-chart-endpoints"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>HTTP status codes</h4><div class="gw-chart-box"><canvas id="gw-chart-status-codes"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Streaming vs non-streaming</h4><div class="gw-chart-box"><canvas id="gw-chart-streaming"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Top failure reasons</h4><div class="gw-chart-box"><canvas id="gw-chart-failure-reasons"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Requests by hour of day (UTC)</h4><div class="gw-chart-box"><canvas id="gw-chart-hourly"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Latency distribution</h4><div class="gw-chart-box"><canvas id="gw-chart-latency-hist"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Tokens/sec distribution</h4><div class="gw-chart-box"><canvas id="gw-chart-tps-hist"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Model reliability weight</h4><div class="gw-chart-box"><canvas id="gw-chart-weight"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Prompt vs completion tokens</h4><div class="gw-chart-box"><canvas id="gw-chart-tokens"></canvas></div></div>
|
||||
</div>
|
||||
|
||||
<section class="gw-section">
|
||||
<div class="gw-section-head">
|
||||
<h3>Per-model detail</h3>
|
||||
<select id="gw-model-detail-select"><option value="">Select a model...</option></select>
|
||||
</div>
|
||||
<div id="gw-model-detail-empty-state" class="gw-section-hint">Pick a model above to see its own timeseries and latency distribution.</div>
|
||||
<div id="gw-model-detail-charts" hidden>
|
||||
<div class="gw-tiles">
|
||||
<div class="gw-tile"><div class="gw-tile-label">Requests</div><div class="gw-tile-value" id="gw-model-detail-requests">-</div></div>
|
||||
<div class="gw-tile"><div class="gw-tile-label">Success rate</div><div class="gw-tile-value" id="gw-model-detail-success-rate">-</div></div>
|
||||
<div class="gw-tile"><div class="gw-tile-label">Avg latency</div><div class="gw-tile-value" id="gw-model-detail-latency">-</div></div>
|
||||
<div class="gw-tile"><div class="gw-tile-label">Avg tokens/sec</div><div class="gw-tile-value" id="gw-model-detail-tps">-</div></div>
|
||||
</div>
|
||||
<div class="gw-charts-grid">
|
||||
<div class="gw-panel gw-wide"><h4>Requests over time</h4><div class="gw-chart-box"><canvas id="gw-chart-model-timeseries"></canvas></div></div>
|
||||
<div class="gw-panel"><h4>Latency distribution</h4><div class="gw-chart-box"><canvas id="gw-chart-model-latency-hist"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="gw-section">
|
||||
<h3>Model pool</h3>
|
||||
<div class="admin-table-wrap gw-table-scroll">
|
||||
<table class="admin-table">
|
||||
<caption class="sr-only">Model pool</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Model</th><th scope="col">Provider</th><th scope="col">Requests</th><th scope="col">Success</th><th scope="col">Avg latency</th><th scope="col">Avg tok/s</th><th scope="col">Weight</th><th scope="col">Circuit</th><th scope="col">Prompt tok</th><th scope="col">Completion tok</th></tr>
|
||||
</thead>
|
||||
<tbody id="gw-stats-model-table-body">
|
||||
<tr><td colspan="10" class="admin-empty">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="gw-section">
|
||||
<h3>Recent failures</h3>
|
||||
<div class="admin-table-wrap gw-table-scroll">
|
||||
<table class="admin-table">
|
||||
<caption class="sr-only">Recent failures</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">When</th><th scope="col">Model</th><th scope="col">Provider</th><th scope="col">Endpoint</th><th scope="col">Status</th><th scope="col">Reason</th><th scope="col">Fell back to</th></tr>
|
||||
</thead>
|
||||
<tbody id="gw-stats-failures-table-body">
|
||||
<tr><td colspan="7" class="admin-empty">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
{% if tab == 'stats' %}
|
||||
<script src="{{ static_url('/static/js/vendor/chart.js') }}"></script>
|
||||
<script type="module">
|
||||
import { GatewayAdmin } from "{{ static_url('/static/js/GatewayAdmin.js') }}";
|
||||
new GatewayAdmin(document.getElementById("gateway-admin")).start();
|
||||
import { GatewayStats } from "{{ static_url('/static/js/GatewayStats.js') }}";
|
||||
const gatewayStats = new GatewayStats(document.querySelector("[data-stats-root]"));
|
||||
gatewayStats.start();
|
||||
window.app.gatewayStats = gatewayStats;
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gateway.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>{{ "Edit model route" if is_edit else "Add model route" }}</h2>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway?tab=models">← Back to model routes</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<p class="gw-error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form id="gateway-model-form" method="post" action="{{ '/admin/gateway/models/' + form.source_model + '/edit' if is_edit else '/admin/gateway/models/new' }}" class="gw-page-form" role="group" aria-label="{{ 'Edit model route' if is_edit else 'Add model route' }}">
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Routing</legend>
|
||||
<p class="gw-section-hint">The source model is the name clients request; the target model is what is actually sent to the provider.</p>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-source">Source model (requested)</label>
|
||||
<input type="text" id="gw-model-source" name="source_model" placeholder="gpt-4o" value="{{ form.source_model }}" required aria-required="true" {% if is_edit %}readonly{% endif %}>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-provider">Provider</label>
|
||||
<select id="gw-model-provider" name="provider">
|
||||
<option value="" {% if not form.provider %}selected{% endif %}>default</option>
|
||||
{% for p in providers %}
|
||||
<option value="{{ p.name }}" {% if form.provider == p.name %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-target">Target model (upstream)</label>
|
||||
<input type="text" id="gw-model-target" name="target_model" placeholder="openai/gpt-4o" value="{{ form.target_model }}" required aria-required="true">
|
||||
<select id="gw-model-target-select" name="target_model" required aria-required="true" hidden disabled></select>
|
||||
<p class="gw-field-hint" id="gw-model-target-hint" hidden>Loaded live from the selected provider's model list.</p>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-kind">Kind</label>
|
||||
<select id="gw-model-kind" name="kind">
|
||||
<option value="chat" {% if form.kind == "chat" %}selected{% endif %}>chat</option>
|
||||
<option value="embed" {% if form.kind == "embed" %}selected{% endif %}>embed</option>
|
||||
<option value="image" {% if form.kind == "image" %}selected{% endif %}>image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-context">Context window</label>
|
||||
<input type="number" id="gw-model-context" name="context_window" min="0" value="{{ form.context_window }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-active">Active</label>
|
||||
<select id="gw-model-active" name="is_active">
|
||||
<option value="1" {% if form.is_active == "1" %}selected{% endif %}>Yes</option>
|
||||
<option value="0" {% if form.is_active == "0" %}selected{% endif %}>No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-fallback">Fallback model</label>
|
||||
<select id="gw-model-fallback" name="fallback_model" title="Tried automatically when this model fails after its own retries. Only another route of the same kind is a valid choice.">
|
||||
<option value="" {% if not form.fallback_model %}selected{% endif %}>(none)</option>
|
||||
{% for group in fallback_groups %}
|
||||
<optgroup label="{{ group.kind }}">
|
||||
{% for name in group.options %}
|
||||
<option value="{{ name }}" {% if form.fallback_model == name %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Vision augmentation (chat only)</legend>
|
||||
<p class="gw-section-hint">When set, image content is described by this vision model before the chat request is forwarded. Leave blank to disable image-to-text merging for this route.</p>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-vision-provider">Vision provider</label>
|
||||
<select id="gw-model-vision-provider" name="vision_provider">
|
||||
<option value="" {% if not form.vision_provider %}selected{% endif %}>default</option>
|
||||
{% for p in providers %}
|
||||
<option value="{{ p.name }}" {% if form.vision_provider == p.name %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-vision-model">Vision model</label>
|
||||
<input type="text" id="gw-model-vision-model" name="vision_model" placeholder="(optional) google/gemma-3-12b-it" value="{{ form.vision_model }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Pricing</legend>
|
||||
<p class="gw-section-hint">Chat uses cache-hit, cache-miss and output prices (USD per 1M tokens). Embeddings and the vision augmentation use the input price. Image routes use the input price as a flat USD amount per generated image.</p>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-cache-hit">Price cache-hit / 1M ($) - chat</label>
|
||||
<input type="number" id="gw-model-price-cache-hit" name="price_cache_hit_per_m" min="0" step="0.0001" value="{{ form.price_cache_hit_per_m }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-cache-miss">Price cache-miss / 1M ($) - chat</label>
|
||||
<input type="number" id="gw-model-price-cache-miss" name="price_cache_miss_per_m" min="0" step="0.0001" value="{{ form.price_cache_miss_per_m }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-output">Price output / 1M ($) - chat</label>
|
||||
<input type="number" id="gw-model-price-output" name="price_output_per_m" min="0" step="0.0001" value="{{ form.price_output_per_m }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-input">Price input / 1M ($) - embed/vision, or $/image for image routes</label>
|
||||
<input type="number" id="gw-model-price-input" name="price_input_per_m" min="0" step="0.0001" value="{{ form.price_input_per_m }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Tiered pricing (chat & embed, optional)</legend>
|
||||
<p class="gw-section-hint">Some providers charge a different rate once a request crosses a context-length threshold. Leave the tier-2 fields blank to keep the flat rates above at all times; 0 disables the threshold.</p>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-tier-threshold">Tier-2 threshold (input tokens)</label>
|
||||
<input type="number" id="gw-model-tier-threshold" name="context_tier_threshold_tokens" min="0" value="{{ form.context_tier_threshold_tokens }}" title="0 disables tiered pricing">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-cache-hit-tier2">Tier-2 price cache-hit / 1M ($)</label>
|
||||
<input type="number" id="gw-model-price-cache-hit-tier2" name="price_cache_hit_per_m_tier2" min="0" step="0.0001" value="{{ form.price_cache_hit_per_m_tier2 }}" placeholder="same as tier 1">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-cache-miss-tier2">Tier-2 price cache-miss / 1M ($)</label>
|
||||
<input type="number" id="gw-model-price-cache-miss-tier2" name="price_cache_miss_per_m_tier2" min="0" step="0.0001" value="{{ form.price_cache_miss_per_m_tier2 }}" placeholder="same as tier 1">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-output-tier2">Tier-2 price output / 1M ($)</label>
|
||||
<input type="number" id="gw-model-price-output-tier2" name="price_output_per_m_tier2" min="0" step="0.0001" value="{{ form.price_output_per_m_tier2 }}" placeholder="same as tier 1">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-price-input-tier2">Tier-2 price input / 1M ($)</label>
|
||||
<input type="number" id="gw-model-price-input-tier2" name="price_input_per_m_tier2" min="0" step="0.0001" value="{{ form.price_input_per_m_tier2 }}" placeholder="same as tier 1">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Off-peak discount (optional)</legend>
|
||||
<p class="gw-section-hint">A percentage discount applied to whichever tier's rates are active during a fixed UTC time window. Leave both times blank to disable.</p>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-off-peak-start">Off-peak start (UTC)</label>
|
||||
<input type="time" id="gw-model-off-peak-start" name="off_peak_start" value="{{ form.off_peak_start }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-off-peak-end">Off-peak end (UTC)</label>
|
||||
<input type="time" id="gw-model-off-peak-end" name="off_peak_end" value="{{ form.off_peak_end }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-model-off-peak-discount">Off-peak discount (%)</label>
|
||||
<input type="number" id="gw-model-off-peak-discount" name="off_peak_discount_pct" min="0" max="100" step="0.1" value="{{ form.off_peak_discount_pct }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="gw-page-actions">
|
||||
<button type="submit" class="admin-btn admin-btn-primary">{{ "Save model route" if is_edit else "Add model route" }}</button>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway?tab=models">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
import { GatewayModelForm } from "{{ static_url('/static/js/GatewayModelForm.js') }}";
|
||||
const gatewayModelForm = new GatewayModelForm(document.getElementById("gateway-model-form"));
|
||||
gatewayModelForm.start();
|
||||
window.app.gatewayModelForm = gatewayModelForm;
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gateway.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>{{ "Edit provider" if is_edit else "Add provider" }}</h2>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway?tab=providers">← Back to providers</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<p class="gw-error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ '/admin/gateway/providers/' + form.name + '/edit' if is_edit else '/admin/gateway/providers/new' }}" class="gw-page-form" role="group" aria-label="{{ 'Edit provider' if is_edit else 'Add provider' }}">
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Provider</legend>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-provider-name">Name</label>
|
||||
<input type="text" id="gw-provider-name" name="name" placeholder="openrouter" value="{{ form.name }}" required aria-required="true" {% if is_edit %}readonly{% endif %}>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-provider-base-url">Base URL (chat completions)</label>
|
||||
<input type="url" id="gw-provider-base-url" name="base_url" placeholder="https://openrouter.ai/api/v1/chat/completions" value="{{ form.base_url }}">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-provider-api-key">API key</label>
|
||||
<input type="password" id="gw-provider-api-key" name="api_key" value="{{ form.api_key }}" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-provider-active">Active</label>
|
||||
<select id="gw-provider-active" name="is_active">
|
||||
<option value="1" {% if form.is_active == "1" %}selected{% endif %}>Yes</option>
|
||||
<option value="0" {% if form.is_active == "0" %}selected{% endif %}>No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gw-field">
|
||||
<label for="gw-provider-client-profile">Client profile</label>
|
||||
<select id="gw-provider-client-profile" name="client_profile">
|
||||
<option value="" {% if form.client_profile == "" %}selected{% endif %}>Generic (no special headers)</option>
|
||||
<option value="opencode" {% if form.client_profile == "opencode" %}selected{% endif %}>OpenCode Zen (spoof opencode CLI identity)</option>
|
||||
</select>
|
||||
<p class="gw-field-hint">Only needed for an upstream that rejects requests unless they look like they came from a specific client.</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="gw-page-actions">
|
||||
<button type="submit" class="admin-btn admin-btn-primary">{{ "Save provider" if is_edit else "Add provider" }}</button>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway?tab=providers">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gateway.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>{{ "Edit quota rule" if is_edit else "Add quota rule" }}</h2>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway?tab=quota">← Back to quota rules</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<p class="gw-error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ '/admin/gateway/quota-rules/' + uid + '/edit' if is_edit else '/admin/gateway/quota-rules/new' }}" class="gw-page-form" role="group" aria-label="{{ 'Edit quota rule' if is_edit else 'Add quota rule' }}">
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Scope</legend>
|
||||
<p class="gw-section-hint">At least one of role, user, or app reference is required. Leaving a dimension blank makes it a wildcard, pooling spend across everyone matching the remaining dimensions.</p>
|
||||
<div class="gw-field-grid">
|
||||
<div class="gw-field">
|
||||
<label for="gw-quota-owner-kind">Role</label>
|
||||
<select id="gw-quota-owner-kind" name="owner_kind">
|
||||
<option value="" {% if not form.owner_kind %}selected{% endif %}>any</option>
|
||||
<option value="user" {% if form.owner_kind == "user" %}selected{% endif %}>member</option>
|
||||
<option value="admin" {% if form.owner_kind == "admin" %}selected{% endif %}>admin</option>
|
||||
<option value="anonymous" {% if form.owner_kind == "anonymous" %}selected{% endif %}>guest (unauthenticated)</option>
|
||||
<option value="internal" {% if form.owner_kind == "internal" %}selected{% endif %}>internal (DevPlace's own services)</option>
|
||||
<option value="key" {% if form.owner_kind == "key" %}selected{% endif %}>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" value="{{ form.owner_id }}">
|
||||
</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" value="{{ form.app_reference }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="gw-fieldset">
|
||||
<legend>Limit</legend>
|
||||
<div class="gw-field-grid">
|
||||
<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="{{ form.limit_usd }}" 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" {% if form.is_active == "1" %}selected{% endif %}>Yes</option>
|
||||
<option value="0" {% if form.is_active == "0" %}selected{% endif %}>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" value="{{ form.label }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="gw-page-actions">
|
||||
<button type="submit" class="admin-btn admin-btn-primary">{{ "Save quota rule" if is_edit else "Add quota rule" }}</button>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/gateway?tab=quota">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,9 @@
|
||||
<div class="svc-tabbar" role="tablist">
|
||||
<button type="button" class="svc-tab" data-tab="overview">Overview</button>
|
||||
<button type="button" class="svc-tab" data-tab="config">Configuration</button>
|
||||
{% if tool_groups is not none %}
|
||||
<button type="button" class="svc-tab" data-tab="tools">Tools</button>
|
||||
{% endif %}
|
||||
<button type="button" class="svc-tab" data-tab="logs">Logs</button>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +94,46 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if tool_groups is not none %}
|
||||
<section class="svc-tabpane" data-tab-pane="tools">
|
||||
<p class="hint-text devii-tools-intro">
|
||||
Every tool Devii can call costs tokens on every turn just by being listed - disable what a
|
||||
deployment does not need to spare context. Changes apply to every Devii session on the next turn.
|
||||
</p>
|
||||
<form class="devii-tools-form" data-tools-form>
|
||||
<div class="devii-tools-toolbar">
|
||||
<input type="search" class="devii-tools-search" data-tools-search placeholder="Search tools by name or description..." aria-label="Search Devii tools">
|
||||
<span class="devii-tools-summary" data-tools-summary role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="devii-tools-groups">
|
||||
{% for group in tool_groups %}
|
||||
<details class="devii-tools-group" data-tools-group="{{ group.key }}">
|
||||
<summary class="devii-tools-group-summary">
|
||||
<input type="checkbox" class="devii-tools-group-checkbox" data-group-toggle="{{ group.key }}" aria-label="Toggle all tools in {{ group.label }}">
|
||||
<span class="devii-tools-group-label">{{ group.label }}</span>
|
||||
<span class="devii-tools-group-count" data-group-count="{{ group.key }}">{{ group.enabled_count }}/{{ group.total_count }} enabled</span>
|
||||
</summary>
|
||||
<div class="devii-tools-list">
|
||||
{% for tool in group.tools %}
|
||||
<label class="devii-tools-item" data-tools-item data-tools-item-label="{{ (tool.name ~ ' ' ~ tool.summary)|lower }}">
|
||||
<input type="checkbox" name="enabled" value="{{ tool.name }}" data-group="{{ group.key }}" {% if not tool.disabled %}checked{% endif %}>
|
||||
<span class="devii-tools-item-name">{{ tool.name }}</span>
|
||||
{% if tool.requires_primary_admin %}<span class="devii-tools-badge">primary admin</span>{% elif tool.requires_admin %}<span class="devii-tools-badge">admin</span>{% endif %}
|
||||
{% if tool.summary %}<span class="devii-tools-item-summary">{{ tool.summary }}</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button type="submit" class="btn btn-primary">Save tool configuration</button>
|
||||
<span class="config-status" data-tools-status role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="svc-tabpane" data-tab-pane="logs">
|
||||
<div class="service-log">
|
||||
<div class="log-header" id="service-log-header">Log</div>
|
||||
@@ -108,7 +151,13 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module" src="{{ static_url('/static/js/ServiceMonitor.js') }}"></script>
|
||||
{% if tool_groups is not none %}
|
||||
<script type="module" src="{{ static_url('/static/js/DeviiToolsConfig.js') }}"></script>
|
||||
{% endif %}
|
||||
<script type="module">
|
||||
new window.ServiceMonitor().start();
|
||||
{% if tool_groups is not none %}
|
||||
new window.DeviiToolsConfig().start();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -505,6 +505,7 @@ Both carry `metadata.provider` (`webpush`, `apns`, ...) plus `created`, `revived
|
||||
| Event key | Recorded in |
|
||||
|---|---|
|
||||
| `service.config.update` | `routers/admin/services.py` |
|
||||
| `service.devii_tools.update` | `routers/admin/services.py` |
|
||||
| `service.logs.clear` | `routers/admin/services.py` |
|
||||
| `service.run_now` | `routers/admin/services.py` |
|
||||
| `service.start` | `routers/admin/services.py` |
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# DevPlace Changelog — Last 24 Hours
|
||||
|
||||
## AI Gateway — Non-OpenAI Upstreams & Client Model Passthrough
|
||||
|
||||
The AI gateway now supports routing to non-OpenAI upstream providers. The gateway can target any compatible API endpoint, and client-specified model names are passed through to the upstream instead of being forced to a fixed model. This opens the door for self-hosted providers and alternative APIs.
|
||||
|
||||
**Files:** `services/openai_gateway/gateway.py`, `service.py`, `thinking.py`, `vision.py`
|
||||
|
||||
## AI Gateway — Trust Only Upstream X-Gateway-Model Header
|
||||
|
||||
The gateway now trusts only the `X-Gateway-Model` header from the upstream response when determining which model served a request. This prevents spoofing and ensures usage attribution is accurate.
|
||||
|
||||
**Files:** `services/openai_gateway/gateway.py`, `usage.py`
|
||||
|
||||
## Backup Service — Remote Offload to Hetzner Storage Box
|
||||
|
||||
Completed backups can now be automatically offloaded to a remote Hetzner Storage Box. The offload module handles the transfer, and the backup service orchestrates the full lifecycle: create, verify, offload, and prune.
|
||||
|
||||
**Files:** `services/backup/offload.py`, `service.py`, `store.py`
|
||||
|
||||
## Container Manager — Orphan Blob Fix & System Prune
|
||||
|
||||
Fixed container sync races that leaked orphan blobs (files on disk with no database reference). Added a new `devplace system prune` CLI command that safely removes orphan blobs, sweeps unreferenced container workspace dirs, and prunes expired fork/zip job rows. The command supports `--dry-run` for preview.
|
||||
|
||||
**Files:** `cli/system.py`, `services/containers/store.py`, `attachments.py`, `project_files.py`
|
||||
|
||||
## Container Manager — Two-Phase Plan/Execute Report
|
||||
|
||||
The `system prune` CLI command now uses a two-phase plan/execute report: first it shows what would be deleted (plan), then executes and reports what was actually removed.
|
||||
|
||||
**Files:** `cli/system.py`, `cli/containers.py`
|
||||
|
||||
## Post Page — Happy 404, Featured/Related Sidebars, Next-Post Nav
|
||||
|
||||
The post page now includes:
|
||||
- A "Happy 404" page for missing posts
|
||||
- Featured posts sidebar
|
||||
- Related posts sidebar
|
||||
- Next-post navigation
|
||||
|
||||
**Files:** `happy404.py`, `routers/posts.py`, `templates/post.html`, `database/content.py`
|
||||
|
||||
## Static Asset Cache-Busting — Unified with Auto-Bumped App Version
|
||||
|
||||
Static asset URLs now use the auto-bumped app version for cache-busting instead of a separate timestamp. This ensures assets are invalidated whenever the app version changes, and eliminates the need for manual cache-bust updates.
|
||||
|
||||
**Files:** `config.py`, `templates/docs/static-caching.html`, `nginx/nginx.conf.template`
|
||||
|
||||
## Automatic Patch-Version Bumping via Git Pre-Commit Hook
|
||||
|
||||
A new `.githooks/pre-commit` hook automatically bumps the patch version in `pyproject.toml` on every commit. The hook increments patch (e.g. `1.0.0` → `1.0.1`) and stages the change. A hand-set major/minor bump in the same commit is left untouched. The hook defers during merges and never blocks a commit.
|
||||
|
||||
**Files:** `.githooks/pre-commit`, `Makefile`, `CLAUDE.md`
|
||||
|
||||
## Test Infrastructure — Shared `run_async` Helper
|
||||
|
||||
Provision tests now use the shared `run_async` test helper instead of bare `asyncio.run` calls, improving consistency and error handling across the test suite.
|
||||
|
||||
**Files:** `tests/unit/services/containers/workspace/provision.py`
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
- Updated `README.md` with new feature documentation
|
||||
- Updated `CLAUDE.md` with new CLI commands and architecture notes
|
||||
- Updated docs pages: production nginx, static caching, backups, architecture backend
|
||||
- Updated nested `CLAUDE.md` files for gateway, containers, backup, and posts subsystems
|
||||
|
||||
---
|
||||
|
||||
*Generated from git log — all commits from the last 24 hours.*
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "devplacepy"
|
||||
version = "1.0.13"
|
||||
version = "1.0.14"
|
||||
description = "DevPlace - The Developer Social Network"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -66,6 +66,32 @@ def test_gateway_page_requires_admin(seeded_db):
|
||||
|
||||
def test_gateway_page_renders_for_admin(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(f"{BASE_URL}/admin/gateway", headers={})
|
||||
response = admin.get(f"{BASE_URL}/admin/gateway", headers={"Accept": "text/html"})
|
||||
assert response.status_code == 200
|
||||
assert "Gateway routing" in response.text
|
||||
|
||||
|
||||
def test_gateway_page_tabs_default_and_select_content(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
|
||||
default_page = admin.get(f"{BASE_URL}/admin/gateway", headers={"Accept": "text/html"})
|
||||
assert "Model routes" in default_page.text
|
||||
assert "GatewayAdmin" not in default_page.text
|
||||
|
||||
providers_page = admin.get(f"{BASE_URL}/admin/gateway?tab=providers", headers={"Accept": "text/html"})
|
||||
assert "Providers" in providers_page.text
|
||||
assert "Model routes" not in providers_page.text
|
||||
|
||||
quota_page = admin.get(f"{BASE_URL}/admin/gateway?tab=quota", headers={"Accept": "text/html"})
|
||||
assert "Quota rules" in quota_page.text
|
||||
assert "Model routes" not in quota_page.text
|
||||
|
||||
unknown_tab_page = admin.get(f"{BASE_URL}/admin/gateway?tab=bogus", headers={"Accept": "text/html"})
|
||||
assert "Model routes" in unknown_tab_page.text
|
||||
|
||||
|
||||
def test_gateway_page_json_reports_active_tab(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(f"{BASE_URL}/admin/gateway?tab=providers")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tab"] == "providers"
|
||||
|
||||
@@ -224,6 +224,113 @@ def test_model_route_fallback_rejects_self_reference(seeded_db):
|
||||
assert response.json()["ok"] is False
|
||||
|
||||
|
||||
def test_model_form_pages_require_admin(seeded_db):
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/models/new",
|
||||
headers=JSON_gateway,
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
key = member_key()
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/models/new",
|
||||
headers={**JSON_gateway, "X-API-KEY": key},
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_model_add_edit_delete_via_page(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
source = _unique_gateway("modelpage")
|
||||
|
||||
new_page = admin.get(f"{BASE_URL}/admin/gateway/models/new")
|
||||
assert new_page.status_code == 200
|
||||
assert new_page.json()["is_edit"] is False
|
||||
|
||||
created = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/models/new",
|
||||
data={
|
||||
"source_model": source,
|
||||
"target_model": "vendor/page",
|
||||
"kind": "chat",
|
||||
"price_output_per_m": "1.5",
|
||||
"off_peak_start": "22:30",
|
||||
"off_peak_end": "06:00",
|
||||
"off_peak_discount_pct": "10",
|
||||
"is_active": "1",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert created.status_code == 302
|
||||
assert created.headers["location"] == "/admin/gateway?tab=models"
|
||||
|
||||
edit_page = admin.get(f"{BASE_URL}/admin/gateway/models/{source}/edit")
|
||||
assert edit_page.status_code == 200
|
||||
form = edit_page.json()["form"]
|
||||
assert form["target_model"] == "vendor/page"
|
||||
assert form["off_peak_start"] == "22:30"
|
||||
assert form["off_peak_end"] == "06:00"
|
||||
|
||||
edit_html = admin.get(f"{BASE_URL}/admin/gateway/models/{source}/edit", headers={"Accept": "text/html"})
|
||||
assert f'value="{source}"' in edit_html.text
|
||||
assert "readonly" in edit_html.text
|
||||
|
||||
updated = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/models/{source}/edit",
|
||||
data={"target_model": "vendor/page2", "kind": "chat", "is_active": "1"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert updated.status_code == 302
|
||||
|
||||
relisted = admin.get(f"{BASE_URL}/admin/gateway/models").json()
|
||||
row = next(m for m in relisted["models"] if m["source_model"] == source)
|
||||
assert row["target_model"] == "vendor/page2"
|
||||
|
||||
deleted = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/models/{source}/delete", allow_redirects=False
|
||||
)
|
||||
assert deleted.status_code == 302
|
||||
assert admin.get(f"{BASE_URL}/admin/gateway/models/{source}/edit").status_code == 404
|
||||
|
||||
|
||||
def test_model_page_fallback_must_be_the_same_kind(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
embed_route = _unique_gateway("fbpage-embed")
|
||||
chat_route = _unique_gateway("fbpage-chat")
|
||||
|
||||
admin.post(
|
||||
f"{BASE_URL}/admin/gateway/models",
|
||||
json={"source_model": embed_route, "target_model": "vendor/e", "kind": "embed"},
|
||||
)
|
||||
response = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/models/new",
|
||||
data={
|
||||
"source_model": chat_route,
|
||||
"target_model": "vendor/c",
|
||||
"kind": "chat",
|
||||
"fallback_model": embed_route,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "message" in response.json()["error"]
|
||||
|
||||
admin.delete(f"{BASE_URL}/admin/gateway/models/{embed_route}")
|
||||
|
||||
|
||||
def test_model_edit_page_404_for_missing_model(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
missing = _unique_gateway("ghostmodel")
|
||||
assert admin.get(f"{BASE_URL}/admin/gateway/models/{missing}/edit").status_code == 404
|
||||
assert (
|
||||
admin.post(f"{BASE_URL}/admin/gateway/models/{missing}/delete").status_code == 404
|
||||
)
|
||||
|
||||
|
||||
def test_model_route_validation(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
missing_target = admin.post(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from tests.api.admin.gateway.index import (
|
||||
JSON_gateway,
|
||||
admin_session,
|
||||
member_key,
|
||||
_unique_gateway,
|
||||
)
|
||||
|
||||
|
||||
def _fake_upstream(status_code, payload):
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
body = json.dumps(payload).encode() if payload is not None else b""
|
||||
seen_auth = {}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
seen_auth["path"] = self.path
|
||||
seen_auth["authorization"] = self.headers.get("Authorization")
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
httpd = socketserver.TCPServer(("127.0.0.1", port), Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return httpd, port, seen_auth
|
||||
|
||||
|
||||
def test_provider_models_requires_admin(seeded_db):
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/provider-models",
|
||||
headers=JSON_gateway,
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
key = member_key()
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/provider-models",
|
||||
headers={**JSON_gateway, "X-API-KEY": key},
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_provider_models_returns_the_list_on_success(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
name = _unique_gateway("modelsupstream").lower()
|
||||
httpd, port, seen = _fake_upstream(
|
||||
200, {"data": [{"id": "vendor/a"}, {"id": "vendor/b"}]}
|
||||
)
|
||||
try:
|
||||
admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={
|
||||
"name": name,
|
||||
"base_url": f"http://127.0.0.1:{port}/v1/chat/completions",
|
||||
"api_key": "sk-fake",
|
||||
},
|
||||
)
|
||||
response = admin.get(
|
||||
f"{BASE_URL}/admin/gateway/provider-models", params={"provider": name}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["models"] == ["vendor/a", "vendor/b"]
|
||||
assert seen["path"] == "/v1/models"
|
||||
assert seen["authorization"] == "Bearer sk-fake"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
admin.delete(f"{BASE_URL}/admin/gateway/providers/{name}")
|
||||
|
||||
|
||||
def test_provider_models_404_when_upstream_has_no_model_listing(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
name = _unique_gateway("modelsupstream404").lower()
|
||||
httpd, port, _ = _fake_upstream(404, {})
|
||||
try:
|
||||
admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={
|
||||
"name": name,
|
||||
"base_url": f"http://127.0.0.1:{port}/v1/chat/completions",
|
||||
},
|
||||
)
|
||||
response = admin.get(
|
||||
f"{BASE_URL}/admin/gateway/provider-models", params={"provider": name}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
admin.delete(f"{BASE_URL}/admin/gateway/providers/{name}")
|
||||
|
||||
|
||||
def test_provider_models_404_for_unknown_provider(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(
|
||||
f"{BASE_URL}/admin/gateway/provider-models",
|
||||
params={"provider": _unique_gateway("ghostprovider")},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -82,3 +82,115 @@ def test_provider_name_validation(seeded_db):
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
assert bad.json()["ok"] is False
|
||||
|
||||
|
||||
def test_provider_form_pages_require_admin(seeded_db):
|
||||
name = _unique_gateway("provpage").lower()
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/providers/new",
|
||||
headers=JSON_gateway,
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
key = member_key()
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/providers/new",
|
||||
headers={**JSON_gateway, "X-API-KEY": key},
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
requests.post(
|
||||
f"{BASE_URL}/admin/gateway/providers/new",
|
||||
data={"name": name},
|
||||
headers={**JSON_gateway, "X-API-KEY": key},
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_provider_add_edit_delete_via_page(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
name = _unique_gateway("provpage").lower()
|
||||
|
||||
new_page = admin.get(f"{BASE_URL}/admin/gateway/providers/new")
|
||||
assert new_page.status_code == 200
|
||||
assert new_page.json()["is_edit"] is False
|
||||
|
||||
created = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers/new",
|
||||
data={
|
||||
"name": name,
|
||||
"base_url": "https://page.example/v1/chat/completions",
|
||||
"api_key": "sk-page",
|
||||
"is_active": "1",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert created.status_code == 302
|
||||
assert created.headers["location"] == "/admin/gateway?tab=providers"
|
||||
|
||||
edit_page = admin.get(f"{BASE_URL}/admin/gateway/providers/{name}/edit")
|
||||
assert edit_page.status_code == 200
|
||||
edit_body = edit_page.json()
|
||||
assert edit_body["is_edit"] is True
|
||||
assert edit_body["form"]["base_url"] == "https://page.example/v1/chat/completions"
|
||||
|
||||
edit_html = admin.get(f"{BASE_URL}/admin/gateway/providers/{name}/edit", headers={"Accept": "text/html"})
|
||||
assert f'value="{name}"' in edit_html.text
|
||||
assert "readonly" in edit_html.text
|
||||
|
||||
updated = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers/{name}/edit",
|
||||
data={"base_url": "https://page2.example/v1/chat/completions", "is_active": "0"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert updated.status_code == 302
|
||||
|
||||
relisted = admin.get(f"{BASE_URL}/admin/gateway/providers").json()
|
||||
match = next(p for p in relisted["providers"] if p["name"] == name)
|
||||
assert match["base_url"] == "https://page2.example/v1/chat/completions"
|
||||
assert match["is_active"] is False
|
||||
|
||||
deleted = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers/{name}/delete", allow_redirects=False
|
||||
)
|
||||
assert deleted.status_code == 302
|
||||
assert admin.get(f"{BASE_URL}/admin/gateway/providers/{name}/edit").status_code == 404
|
||||
|
||||
|
||||
def test_provider_page_validation_error_rerenders_with_message(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers/new",
|
||||
data={"name": "has spaces!"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "message" in response.json()["error"]
|
||||
|
||||
|
||||
def test_provider_page_validation_error_shows_banner_in_html(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers/new",
|
||||
data={"name": "has spaces!"},
|
||||
headers={"Accept": "text/html"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "gw-error" in response.text
|
||||
assert "letters, numbers, hyphen, underscore" in response.text
|
||||
|
||||
|
||||
def test_provider_edit_page_404_for_missing_provider(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
missing = _unique_gateway("ghostprov").lower()
|
||||
assert admin.get(f"{BASE_URL}/admin/gateway/providers/{missing}/edit").status_code == 404
|
||||
assert (
|
||||
admin.post(f"{BASE_URL}/admin/gateway/providers/{missing}/delete").status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from tests.api.admin.gateway.index import (
|
||||
JSON_gateway,
|
||||
admin_session,
|
||||
member_key,
|
||||
_unique_gateway,
|
||||
)
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
@@ -160,3 +161,121 @@ def test_spend_recorded_after_a_reset_counts_again(seeded_db):
|
||||
)
|
||||
_burn(owner, "appa", 0.5)
|
||||
assert _spent(owner, "appa") == 0.5
|
||||
|
||||
|
||||
def test_quota_rule_form_pages_require_admin(seeded_db):
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/new",
|
||||
headers=JSON_gateway,
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
key = member_key()
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/new",
|
||||
headers={**JSON_gateway, "X-API-KEY": key},
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_quota_rule_add_edit_delete_reset_via_page(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
owner = _owner()
|
||||
app_ref = _unique_gateway("qrapp").lower()
|
||||
|
||||
new_page = admin.get(f"{BASE_URL}/admin/gateway/quota-rules/new")
|
||||
assert new_page.status_code == 200
|
||||
assert new_page.json()["is_edit"] is False
|
||||
|
||||
created = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/new",
|
||||
data={
|
||||
"owner_kind": "user",
|
||||
"owner_id": owner,
|
||||
"app_reference": app_ref,
|
||||
"limit_usd": "2.5",
|
||||
"is_active": "1",
|
||||
"label": "page test",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert created.status_code == 302
|
||||
assert created.headers["location"] == "/admin/gateway?tab=quota"
|
||||
|
||||
rules = admin.get(f"{BASE_URL}/admin/gateway/quota-rules").json()["rules"]
|
||||
rule = next(r for r in rules if r["owner_id"] == owner and r["app_reference"] == app_ref)
|
||||
uid = rule["uid"]
|
||||
assert rule["limit_usd"] == 2.5
|
||||
|
||||
edit_page = admin.get(f"{BASE_URL}/admin/gateway/quota-rules/{uid}/edit")
|
||||
assert edit_page.status_code == 200
|
||||
assert edit_page.json()["form"]["limit_usd"] == "2.5"
|
||||
|
||||
updated = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/{uid}/edit",
|
||||
data={
|
||||
"owner_kind": "user",
|
||||
"owner_id": owner,
|
||||
"app_reference": app_ref,
|
||||
"limit_usd": "5.0",
|
||||
"is_active": "1",
|
||||
"label": "page test updated",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert updated.status_code == 302
|
||||
|
||||
rules = admin.get(f"{BASE_URL}/admin/gateway/quota-rules").json()["rules"]
|
||||
rule = next(r for r in rules if r["uid"] == uid)
|
||||
assert rule["limit_usd"] == 5.0
|
||||
assert rule["label"] == "page test updated"
|
||||
|
||||
_burn(owner, app_ref, 1.0)
|
||||
assert _spent(owner, app_ref) == 1.0
|
||||
reset_response = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/{uid}/reset", allow_redirects=False
|
||||
)
|
||||
assert reset_response.status_code == 302
|
||||
assert _spent(owner, app_ref) == 0.0
|
||||
|
||||
deleted = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/{uid}/delete", allow_redirects=False
|
||||
)
|
||||
assert deleted.status_code == 302
|
||||
assert (
|
||||
admin.get(f"{BASE_URL}/admin/gateway/quota-rules/{uid}/edit").status_code == 404
|
||||
)
|
||||
|
||||
|
||||
def test_quota_rule_page_requires_a_dimension(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/quota-rules/new",
|
||||
data={"limit_usd": "1.0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "message" in response.json()["error"]
|
||||
|
||||
|
||||
def test_quota_rule_edit_page_404_for_missing_rule(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
missing = generate_uid()
|
||||
assert (
|
||||
admin.get(f"{BASE_URL}/admin/gateway/quota-rules/{missing}/edit").status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
admin.post(f"{BASE_URL}/admin/gateway/quota-rules/{missing}/delete").status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
admin.post(f"{BASE_URL}/admin/gateway/quota-rules/{missing}/reset").status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import requests
|
||||
|
||||
from tests.api.admin.gateway.index import JSON_gateway, admin_session, member_key
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_stats_tab_renders_for_admin(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(
|
||||
f"{BASE_URL}/admin/gateway?tab=stats", headers={"Accept": "text/html"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Model pool" in response.text
|
||||
assert "Model routes" not in response.text
|
||||
|
||||
|
||||
def test_stats_data_requires_admin(seeded_db):
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/stats/data", headers=JSON_gateway, allow_redirects=False
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
key = member_key()
|
||||
assert (
|
||||
requests.get(
|
||||
f"{BASE_URL}/admin/gateway/stats/data",
|
||||
headers={**JSON_gateway, "X-API-KEY": key},
|
||||
allow_redirects=False,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_stats_data_shape_for_admin(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(f"{BASE_URL}/admin/gateway/stats/data?range=24h", headers=JSON_gateway)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for key in (
|
||||
"totals",
|
||||
"per_model",
|
||||
"per_endpoint",
|
||||
"per_provider",
|
||||
"timeseries",
|
||||
"status_codes",
|
||||
"streaming_split",
|
||||
"failure_reasons",
|
||||
"hourly_distribution",
|
||||
"recent_failures",
|
||||
"latency_histogram",
|
||||
"tokens_per_second_histogram",
|
||||
"bucket_seconds",
|
||||
):
|
||||
assert key in data
|
||||
|
||||
|
||||
def test_stats_data_rejects_unknown_range(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(f"{BASE_URL}/admin/gateway/stats/data?range=nonsense", headers=JSON_gateway)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_stats_models_returns_a_list(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(f"{BASE_URL}/admin/gateway/stats/models", headers=JSON_gateway)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json()["models"], list)
|
||||
|
||||
|
||||
def test_stats_model_detail_for_an_unknown_model(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
response = admin.get(
|
||||
f"{BASE_URL}/admin/gateway/stats/model/default/never-called-model?range=24h",
|
||||
headers=JSON_gateway,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["summary"]["total_requests"] == 0
|
||||
assert data["health"] is None
|
||||
@@ -0,0 +1,115 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import clear_settings_cache, get_table, refresh_snapshot
|
||||
from devplacepy.services.devii import tool_prefs
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
_counter = [0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _settings(app_server):
|
||||
from devplacepy.database import set_setting
|
||||
|
||||
set_setting("rate_limit_per_minute", "1000000")
|
||||
set_setting("registration_open", "1")
|
||||
yield
|
||||
|
||||
|
||||
def _admin(seeded_db):
|
||||
refresh_snapshot()
|
||||
key = get_table("users").find_one(username="alice_test")["api_key"]
|
||||
s = requests.Session()
|
||||
s.headers.update({"X-API-KEY": key, **JSON})
|
||||
return s
|
||||
|
||||
|
||||
def _member():
|
||||
_counter[0] += 1
|
||||
name = f"dtmem{int(time.time() * 1000)}{_counter[0]}"
|
||||
requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
refresh_snapshot()
|
||||
s = requests.Session()
|
||||
s.headers.update(
|
||||
{"X-API-KEY": get_table("users").find_one(username=name)["api_key"], **JSON}
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def _all_tool_names():
|
||||
return set(tool_prefs.GROUPS_BY_TOOL_NAME)
|
||||
|
||||
|
||||
def test_devii_service_page_has_tools_tab(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
r = admin.get(f"{BASE_URL}/admin/services/devii")
|
||||
assert r.status_code == 200
|
||||
assert 'data-tab="tools"' in r.text
|
||||
assert "devii-tools-form" in r.text
|
||||
|
||||
|
||||
def test_other_service_page_has_no_tools_tab(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
r = admin.get(f"{BASE_URL}/admin/services/news")
|
||||
assert r.status_code == 200
|
||||
assert 'data-tab="tools"' not in r.text
|
||||
|
||||
|
||||
def test_admin_can_disable_and_reenable_a_tool(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
all_names = _all_tool_names()
|
||||
try:
|
||||
enabled = sorted(all_names - {"create_post"})
|
||||
r = admin.post(
|
||||
f"{BASE_URL}/admin/services/devii/tools",
|
||||
data=[("enabled", name) for name in enabled],
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert r.json()["ok"] is True
|
||||
clear_settings_cache()
|
||||
assert tool_prefs.disabled_tool_names() == frozenset({"create_post"})
|
||||
|
||||
r2 = admin.post(
|
||||
f"{BASE_URL}/admin/services/devii/tools",
|
||||
data=[("enabled", name) for name in all_names],
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
clear_settings_cache()
|
||||
assert tool_prefs.disabled_tool_names() == frozenset()
|
||||
finally:
|
||||
clear_settings_cache()
|
||||
tool_prefs.set_disabled_tool_names(set())
|
||||
|
||||
|
||||
def test_member_cannot_save_tool_config(app_server):
|
||||
member = _member()
|
||||
r = member.post(
|
||||
f"{BASE_URL}/admin/services/devii/tools",
|
||||
data={"enabled": "create_post"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (302, 303, 403)
|
||||
|
||||
|
||||
def test_guest_cannot_save_tool_config(app_server):
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/admin/services/devii/tools",
|
||||
data={"enabled": "create_post"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (302, 303, 401)
|
||||
@@ -0,0 +1,138 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
from tests.conftest import BASE_URL, login_user
|
||||
|
||||
|
||||
def _promote_to_admin(username: str) -> None:
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=username)
|
||||
if user:
|
||||
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
|
||||
|
||||
|
||||
def _admin_api_key(username: str) -> str:
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)["api_key"]
|
||||
|
||||
|
||||
def _fake_models_upstream(model_ids):
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
body = json.dumps({"data": [{"id": m} for m in model_ids]}).encode()
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
httpd = socketserver.TCPServer(("127.0.0.1", port), Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return httpd, port
|
||||
|
||||
|
||||
def test_model_form_swaps_target_field_by_provider(page, seeded_db):
|
||||
user = seeded_db["alice"]
|
||||
_promote_to_admin(user["username"])
|
||||
key = _admin_api_key(user["username"])
|
||||
auth = {"X-API-KEY": key}
|
||||
|
||||
httpd, port = _fake_models_upstream(["vendor/known-a", "vendor/known-b"])
|
||||
listing_provider = f"e2elisting{port}"
|
||||
blind_provider = f"e2eblind{port}"
|
||||
try:
|
||||
requests.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={
|
||||
"name": listing_provider,
|
||||
"base_url": f"http://127.0.0.1:{port}/v1/chat/completions",
|
||||
},
|
||||
headers=auth,
|
||||
)
|
||||
requests.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={"name": blind_provider, "base_url": ""},
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
login_user(page, user)
|
||||
page.goto(f"{BASE_URL}/admin/gateway/models/new", wait_until="domcontentloaded")
|
||||
|
||||
target_input = page.locator("#gw-model-target")
|
||||
target_select = page.locator("#gw-model-target-select")
|
||||
target_input.wait_for(state="visible")
|
||||
assert not target_select.is_visible()
|
||||
|
||||
page.select_option("#gw-model-provider", listing_provider)
|
||||
target_select.wait_for(state="visible", timeout=10000)
|
||||
assert not target_input.is_visible()
|
||||
assert target_select.get_attribute("required") is not None
|
||||
options = target_select.locator("option").all_inner_texts()
|
||||
assert "vendor/known-a" in options
|
||||
assert "vendor/known-b" in options
|
||||
|
||||
page.select_option("#gw-model-provider", blind_provider)
|
||||
target_input.wait_for(state="visible", timeout=10000)
|
||||
assert not target_select.is_visible()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
requests.delete(
|
||||
f"{BASE_URL}/admin/gateway/providers/{listing_provider}", headers=auth
|
||||
)
|
||||
requests.delete(
|
||||
f"{BASE_URL}/admin/gateway/providers/{blind_provider}", headers=auth
|
||||
)
|
||||
|
||||
|
||||
def test_model_form_submits_selected_model_from_dropdown(page, seeded_db):
|
||||
user = seeded_db["alice"]
|
||||
_promote_to_admin(user["username"])
|
||||
key = _admin_api_key(user["username"])
|
||||
auth = {"X-API-KEY": key}
|
||||
|
||||
httpd, port = _fake_models_upstream(["vendor/pick-me"])
|
||||
provider = f"e2esubmit{port}"
|
||||
source = f"e2esource{port}"
|
||||
try:
|
||||
requests.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={
|
||||
"name": provider,
|
||||
"base_url": f"http://127.0.0.1:{port}/v1/chat/completions",
|
||||
},
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
login_user(page, user)
|
||||
page.goto(f"{BASE_URL}/admin/gateway/models/new", wait_until="domcontentloaded")
|
||||
page.fill("#gw-model-source", source)
|
||||
page.select_option("#gw-model-provider", provider)
|
||||
page.locator("#gw-model-target-select").wait_for(state="visible", timeout=10000)
|
||||
page.select_option("#gw-model-target-select", "vendor/pick-me")
|
||||
page.click("button[type='submit']")
|
||||
page.wait_for_url(f"{BASE_URL}/admin/gateway?tab=models", wait_until="domcontentloaded")
|
||||
|
||||
listed = requests.get(f"{BASE_URL}/admin/gateway/models", headers=auth).json()
|
||||
row = next(m for m in listed["models"] if m["source_model"] == source)
|
||||
assert row["target_model"] == "vendor/pick-me"
|
||||
assert row["provider"] == provider
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
requests.delete(f"{BASE_URL}/admin/gateway/models/{source}", headers=auth)
|
||||
requests.delete(f"{BASE_URL}/admin/gateway/providers/{provider}", headers=auth)
|
||||
@@ -101,3 +101,36 @@ def test_primary_admin_tool_denied_for_regular_admin_is_audited(monkeypatch):
|
||||
assert event_key == "security.authz.denied"
|
||||
assert kwargs["metadata"]["tool"] == "db_list_tables"
|
||||
assert kwargs["actor_role"] == "admin"
|
||||
|
||||
|
||||
def test_admin_disabled_tool_is_refused_even_for_admin(monkeypatch):
|
||||
from devplacepy.services.devii import tool_prefs
|
||||
|
||||
recorder = _patch_recorder(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
tool_prefs, "disabled_tool_names", lambda: frozenset({"create_post"})
|
||||
)
|
||||
dispatcher = _bare_dispatcher("user", "admin-uid-9", is_admin=True, is_primary_admin=True)
|
||||
|
||||
result = run_async(dispatcher.dispatch("create_post", {"content": "hello"}))
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["error"] == "tool_disabled"
|
||||
assert len(recorder.calls) == 1
|
||||
event_key, kwargs = recorder.calls[0]
|
||||
assert event_key == "security.authz.denied"
|
||||
assert kwargs["metadata"]["tool"] == "create_post"
|
||||
assert kwargs["metadata"]["reason"] == "disabled by administrator"
|
||||
|
||||
|
||||
def test_non_disabled_tool_unaffected_by_disabled_set(monkeypatch):
|
||||
from devplacepy.services.devii import tool_prefs
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_prefs, "disabled_tool_names", lambda: frozenset({"create_post"})
|
||||
)
|
||||
dispatcher = _bare_dispatcher("user", "admin-uid-9", is_admin=True, is_primary_admin=True)
|
||||
|
||||
result = run_async(dispatcher.dispatch("admin_list_users", {}))
|
||||
|
||||
assert json.loads(result).get("error") != "tool_disabled"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.services.devii.agentic.compaction import (
|
||||
compact_messages,
|
||||
find_compaction_split,
|
||||
is_context_length_error,
|
||||
)
|
||||
from devplacepy.services.devii.errors import LLMError
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
def _error(status, body):
|
||||
return LLMError("Model endpoint returned error", status=status, body=body)
|
||||
|
||||
|
||||
def test_openrouter_style_message_detected():
|
||||
body = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": (
|
||||
"This endpoint's maximum context length is 131072 tokens. "
|
||||
"However, you requested about 403355 tokens (349784 of text "
|
||||
"input, 53571 of tool input). Please reduce the length of "
|
||||
"either one, or use the context-compression plugin."
|
||||
),
|
||||
"code": 400,
|
||||
"metadata": {"provider_name": None},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert is_context_length_error(_error(400, body)) is True
|
||||
|
||||
|
||||
def test_openai_style_code_detected():
|
||||
body = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": "This model's maximum context length is 128000 tokens.",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "context_length_exceeded",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert is_context_length_error(_error(400, body)) is True
|
||||
|
||||
|
||||
def test_unrelated_400_not_detected():
|
||||
body = json.dumps({"error": {"message": "Invalid API key.", "code": 400}})
|
||||
assert is_context_length_error(_error(400, body)) is False
|
||||
|
||||
|
||||
def test_non_400_status_not_detected_even_with_matching_text():
|
||||
body = json.dumps(
|
||||
{"error": {"message": "maximum context length is 131072 tokens"}}
|
||||
)
|
||||
assert is_context_length_error(_error(429, body)) is False
|
||||
|
||||
|
||||
def test_malformed_body_falls_back_to_phrase_match():
|
||||
truncated = "maximum context length is 131072 tokens, please reduce the length"
|
||||
assert is_context_length_error(_error(400, truncated)) is True
|
||||
|
||||
|
||||
def test_malformed_body_with_no_match_is_false():
|
||||
assert is_context_length_error(_error(400, "not valid json at all")) is False
|
||||
|
||||
|
||||
class _StubLlm:
|
||||
def __init__(self, summary="a concise summary of the earlier turns"):
|
||||
self.summary = summary
|
||||
self.calls = 0
|
||||
|
||||
async def summarize(self, prompt):
|
||||
self.calls += 1
|
||||
return self.summary
|
||||
|
||||
|
||||
def _tool_call_message(name="run_tool"):
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": name, "arguments": "{}"}}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _tool_result_message(content="result"):
|
||||
return {"role": "tool", "tool_call_id": "c1", "name": "run_tool", "content": content}
|
||||
|
||||
|
||||
def _long_tool_heavy_conversation(rounds=20):
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "start the long task"},
|
||||
]
|
||||
for i in range(rounds):
|
||||
messages.append(_tool_call_message())
|
||||
messages.append(_tool_result_message(f"result {i}" * 200))
|
||||
return messages
|
||||
|
||||
|
||||
def test_find_compaction_split_prefers_a_user_message():
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
{"role": "user", "content": "second"},
|
||||
{"role": "assistant", "content": "reply2"},
|
||||
{"role": "user", "content": "third"},
|
||||
{"role": "assistant", "content": "reply3"},
|
||||
]
|
||||
split = find_compaction_split(messages, keep_tail=2)
|
||||
assert messages[split]["role"] == "user"
|
||||
|
||||
|
||||
def test_find_compaction_split_falls_back_to_a_non_tool_boundary_without_a_recent_user_message():
|
||||
messages = _long_tool_heavy_conversation(rounds=20)
|
||||
split = find_compaction_split(messages, keep_tail=4)
|
||||
assert split > 1
|
||||
assert messages[split].get("role") != "tool"
|
||||
|
||||
|
||||
def test_find_compaction_split_never_lands_inside_a_tool_result_run():
|
||||
messages = _long_tool_heavy_conversation(rounds=30)
|
||||
for keep_tail in (2, 3, 4, 5, 8, 10, 15):
|
||||
split = find_compaction_split(messages, keep_tail)
|
||||
assert messages[split].get("role") != "tool", (
|
||||
f"keep_tail={keep_tail} split at a tool message, orphaning its tool_calls"
|
||||
)
|
||||
|
||||
|
||||
def test_compact_messages_shrinks_a_tool_heavy_conversation_with_no_recent_user_message():
|
||||
messages = _long_tool_heavy_conversation(rounds=20)
|
||||
original_len = len(messages)
|
||||
llm = _StubLlm()
|
||||
compacted = run_async(compact_messages(llm, messages, keep_tail=4))
|
||||
assert llm.calls == 1
|
||||
assert len(compacted) < original_len
|
||||
assert compacted[0]["role"] == "system"
|
||||
assert "[compacted earlier turns]" in compacted[1]["content"]
|
||||
assert compacted[-1] == messages[-1]
|
||||
|
||||
|
||||
def test_compact_messages_tail_never_starts_with_a_dangling_tool_result():
|
||||
messages = _long_tool_heavy_conversation(rounds=25)
|
||||
llm = _StubLlm()
|
||||
compacted = run_async(compact_messages(llm, messages, keep_tail=6))
|
||||
tail = compacted[2:]
|
||||
assert tail
|
||||
assert tail[0].get("role") != "tool"
|
||||
@@ -1,7 +1,17 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from devplacepy.services.devii.agentic.loop import _run_tool_call
|
||||
|
||||
from devplacepy.services.devii.agentic.compaction import context_size
|
||||
from devplacepy.services.devii.agentic.loop import (
|
||||
MAX_CONTEXT_OVERFLOW_RETRIES,
|
||||
_run_tool_call,
|
||||
react_loop,
|
||||
)
|
||||
from devplacepy.services.devii.agentic.state import AgentState
|
||||
from devplacepy.services.devii.config import load_settings
|
||||
from devplacepy.services.devii.errors import LLMError
|
||||
from tests.conftest import run_async
|
||||
class _FakeDispatcher:
|
||||
def __init__(self):
|
||||
@@ -54,3 +64,173 @@ def test_missing_arguments_defaults_to_empty_object():
|
||||
out = _run({"function": {"name": "auth_status"}}, dispatcher)
|
||||
assert out["status"] == "ok"
|
||||
assert dispatcher.calls == [("auth_status", {})]
|
||||
|
||||
|
||||
_CONTEXT_LENGTH_BODY = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": (
|
||||
"This endpoint's maximum context length is 131072 tokens. "
|
||||
"However, you requested about 403355 tokens. Please reduce "
|
||||
"the length."
|
||||
),
|
||||
"code": 400,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _context_length_error():
|
||||
return LLMError(
|
||||
"Model endpoint returned 400: over limit", status=400, body=_CONTEXT_LENGTH_BODY
|
||||
)
|
||||
|
||||
|
||||
def _settings_for_test(keep_tail=4, threshold=10**9):
|
||||
return dataclasses.replace(
|
||||
load_settings(),
|
||||
context_compact_threshold=threshold,
|
||||
context_keep_tail=keep_tail,
|
||||
)
|
||||
|
||||
|
||||
def _long_message_history(count=10):
|
||||
messages = [{"role": "system", "content": "system prompt"}]
|
||||
for i in range(count):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
messages.append({"role": role, "content": f"turn {i}"})
|
||||
return messages
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
def __init__(self, complete_results):
|
||||
self._complete_results = list(complete_results)
|
||||
self.complete_calls = 0
|
||||
self.summarize_calls = 0
|
||||
|
||||
async def complete(self, messages, tools):
|
||||
self.complete_calls += 1
|
||||
result = self._complete_results[
|
||||
min(self.complete_calls, len(self._complete_results)) - 1
|
||||
]
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
async def summarize(self, text):
|
||||
self.summarize_calls += 1
|
||||
return "compacted summary"
|
||||
|
||||
|
||||
def test_context_overflow_triggers_compaction_and_retries():
|
||||
llm = _FakeLLM(
|
||||
[_context_length_error(), {"role": "assistant", "content": "Recovered answer"}]
|
||||
)
|
||||
messages = _long_message_history()
|
||||
result = run_async(
|
||||
react_loop(
|
||||
llm,
|
||||
_FakeDispatcher(),
|
||||
messages,
|
||||
tools=[],
|
||||
state=AgentState(),
|
||||
settings=_settings_for_test(),
|
||||
max_iterations=5,
|
||||
plan_required=False,
|
||||
verify_required=False,
|
||||
)
|
||||
)
|
||||
assert result == "Recovered answer"
|
||||
assert llm.complete_calls == 2
|
||||
assert llm.summarize_calls == 1
|
||||
assert not result.startswith("[model error]")
|
||||
|
||||
|
||||
def test_context_overflow_gives_up_after_max_retries_with_clear_message():
|
||||
llm = _FakeLLM([_context_length_error()])
|
||||
messages = _long_message_history()
|
||||
result = run_async(
|
||||
react_loop(
|
||||
llm,
|
||||
_FakeDispatcher(),
|
||||
messages,
|
||||
tools=[],
|
||||
state=AgentState(),
|
||||
settings=_settings_for_test(),
|
||||
max_iterations=10,
|
||||
plan_required=False,
|
||||
verify_required=False,
|
||||
)
|
||||
)
|
||||
assert result.startswith("[model error]")
|
||||
assert "context length" in result.lower() or "400" in result
|
||||
assert llm.complete_calls == MAX_CONTEXT_OVERFLOW_RETRIES + 1
|
||||
assert 0 < llm.summarize_calls <= MAX_CONTEXT_OVERFLOW_RETRIES
|
||||
|
||||
|
||||
def test_non_context_length_error_never_triggers_compaction():
|
||||
llm = _FakeLLM([LLMError("Model endpoint returned 500: boom", status=500, body="{}")])
|
||||
messages = _long_message_history()
|
||||
result = run_async(
|
||||
react_loop(
|
||||
llm,
|
||||
_FakeDispatcher(),
|
||||
messages,
|
||||
tools=[],
|
||||
state=AgentState(),
|
||||
settings=_settings_for_test(),
|
||||
max_iterations=5,
|
||||
plan_required=False,
|
||||
verify_required=False,
|
||||
)
|
||||
)
|
||||
assert result == "[model error] Model endpoint returned 500: boom"
|
||||
assert llm.complete_calls == 1
|
||||
assert llm.summarize_calls == 0
|
||||
|
||||
|
||||
class _FakeLLMWithRealLimit:
|
||||
def __init__(self, simulated_limit_chars):
|
||||
self.simulated_limit_chars = simulated_limit_chars
|
||||
self.complete_calls = 0
|
||||
self.summarize_calls = 0
|
||||
|
||||
async def complete(self, messages, tools):
|
||||
self.complete_calls += 1
|
||||
if context_size(messages) > self.simulated_limit_chars:
|
||||
raise _context_length_error()
|
||||
return {"role": "assistant", "content": "Recovered answer"}
|
||||
|
||||
async def summarize(self, text):
|
||||
self.summarize_calls += 1
|
||||
return "short summary"
|
||||
|
||||
|
||||
def test_one_oversized_tail_message_alone_still_recovers():
|
||||
giant = "X" * 300_000
|
||||
messages = _long_message_history(16)
|
||||
messages.append(
|
||||
{"role": "tool", "tool_call_id": "1", "name": "big_tool", "content": giant}
|
||||
)
|
||||
messages.append({"role": "user", "content": "please continue"})
|
||||
|
||||
llm = _FakeLLMWithRealLimit(simulated_limit_chars=30_000)
|
||||
result = run_async(
|
||||
react_loop(
|
||||
llm,
|
||||
_FakeDispatcher(),
|
||||
messages,
|
||||
tools=[],
|
||||
state=AgentState(),
|
||||
settings=_settings_for_test(keep_tail=4),
|
||||
max_iterations=10,
|
||||
plan_required=False,
|
||||
verify_required=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "Recovered answer"
|
||||
assert llm.complete_calls > MAX_CONTEXT_OVERFLOW_RETRIES - 1
|
||||
giant_message = next(m for m in messages if m.get("name") == "big_tool")
|
||||
assert len(giant_message["content"]) < len(giant)
|
||||
assert "truncated" in giant_message["content"]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii import tool_prefs
|
||||
|
||||
|
||||
def test_disabled_tool_names_round_trips(local_db):
|
||||
try:
|
||||
tool_prefs.set_disabled_tool_names({"create_post", "delete_post"})
|
||||
assert tool_prefs.disabled_tool_names() == frozenset({"create_post", "delete_post"})
|
||||
finally:
|
||||
tool_prefs.set_disabled_tool_names(set())
|
||||
|
||||
|
||||
def test_set_disabled_tool_names_drops_unknown_names(local_db):
|
||||
try:
|
||||
tool_prefs.set_disabled_tool_names({"create_post", "not_a_real_tool_xyz"})
|
||||
assert tool_prefs.disabled_tool_names() == frozenset({"create_post"})
|
||||
finally:
|
||||
tool_prefs.set_disabled_tool_names(set())
|
||||
|
||||
|
||||
def test_disabled_tool_names_empty_by_default(local_db):
|
||||
tool_prefs.set_disabled_tool_names(set())
|
||||
assert tool_prefs.disabled_tool_names() == frozenset()
|
||||
|
||||
|
||||
def test_filter_disabled_removes_matching_schemas():
|
||||
schemas = [
|
||||
{"function": {"name": "create_post"}},
|
||||
{"function": {"name": "list_posts"}},
|
||||
]
|
||||
result = tool_prefs.filter_disabled(schemas, disabled=frozenset({"create_post"}))
|
||||
assert [s["function"]["name"] for s in result] == ["list_posts"]
|
||||
|
||||
|
||||
def test_filter_disabled_is_a_no_op_when_nothing_disabled():
|
||||
schemas = [{"function": {"name": "create_post"}}]
|
||||
assert tool_prefs.filter_disabled(schemas, disabled=frozenset()) == schemas
|
||||
|
||||
|
||||
def test_group_overview_covers_every_group_and_marks_disabled(local_db):
|
||||
try:
|
||||
tool_prefs.set_disabled_tool_names({"create_post"})
|
||||
overview = tool_prefs.group_overview()
|
||||
assert len(overview) == len(tool_prefs.GROUPS)
|
||||
posts_group = next(g for g in overview if g["key"] == "posts")
|
||||
create_post_tool = next(t for t in posts_group["tools"] if t["name"] == "create_post")
|
||||
assert create_post_tool["disabled"] is True
|
||||
assert posts_group["enabled_count"] == posts_group["total_count"] - 1
|
||||
finally:
|
||||
tool_prefs.set_disabled_tool_names(set())
|
||||
|
||||
|
||||
def test_groups_by_tool_name_covers_every_action_in_every_group():
|
||||
total_actions = sum(len(actions) for actions in tool_prefs.GROUPS.values())
|
||||
assert len(tool_prefs.GROUPS_BY_TOOL_NAME) == total_actions
|
||||
@@ -41,6 +41,10 @@ class FakeResp_openai_gateway:
|
||||
content = payload["choices"][0]["message"].get("content") or ""
|
||||
except (KeyError, IndexError, TypeError):
|
||||
content = ""
|
||||
try:
|
||||
reasoning = payload["choices"][0]["message"].get("reasoning") or ""
|
||||
except (KeyError, IndexError, TypeError):
|
||||
reasoning = ""
|
||||
|
||||
def frame(delta=None, finish=None, usage=None):
|
||||
body = {"id": chunk_id, "object": "chat.completion.chunk", "model": model}
|
||||
@@ -52,6 +56,8 @@ class FakeResp_openai_gateway:
|
||||
return f"data: {json.dumps(body)}"
|
||||
|
||||
yield frame({"role": "assistant"})
|
||||
for i in range(0, len(reasoning), 5):
|
||||
yield frame({"reasoning": reasoning[i : i + 5]})
|
||||
for i in range(0, len(content), 5):
|
||||
yield frame({"content": content[i : i + 5]})
|
||||
yield frame({}, finish="stop")
|
||||
@@ -693,6 +699,59 @@ class FakeAlwaysFailClient_openai_gateway:
|
||||
pass
|
||||
|
||||
|
||||
class FakeBadRequestClient_openai_gateway:
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
|
||||
def build_request(self, method, url, headers=None, json=None, content=None):
|
||||
return FakeRequest(method, url, json)
|
||||
|
||||
async def send(self, request, stream=False):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
return FakeResp_openai_gateway(
|
||||
status=400, payload={"error": {"message": "invalid request: bad param"}}
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_does_not_fall_back_on_an_unrecoverable_bad_request(local_db, monkeypatch):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeBadRequestClient_openai_gateway)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(source_model="fb-badreq-backup", target_model="backup-target")
|
||||
)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(
|
||||
source_model="fb-badreq-primary",
|
||||
target_model="primary-target",
|
||||
fallback_model="fb-badreq-backup",
|
||||
)
|
||||
)
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = False
|
||||
cfg["gateway_max_retries"] = 0
|
||||
rt = svc.runtime()
|
||||
response = run_async(
|
||||
rt.handle_chat(
|
||||
{"model": "fb-badreq-primary", "messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "no_fallback_on_bad_request"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert len(rt._client.calls) == 1
|
||||
finally:
|
||||
routing.model_store.remove("fb-badreq-primary")
|
||||
routing.model_store.remove("fb-badreq-backup")
|
||||
|
||||
|
||||
def test_chat_falls_back_when_the_primary_model_fails(local_db, monkeypatch):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
@@ -735,6 +794,127 @@ def test_chat_falls_back_when_the_primary_model_fails(local_db, monkeypatch):
|
||||
routing.model_store.remove("fb-backup-route")
|
||||
|
||||
|
||||
class FakeContextLengthClient_openai_gateway:
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
|
||||
def build_request(self, method, url, headers=None, json=None, content=None):
|
||||
return FakeRequest(method, url, json)
|
||||
|
||||
async def send(self, request, stream=False):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
body = request.json_body or {}
|
||||
model = body.get("model")
|
||||
if model == "ctx-primary-target":
|
||||
return FakeResp_openai_gateway(
|
||||
status=400,
|
||||
payload={"error": {"message": "maximum context length exceeded"}},
|
||||
)
|
||||
return FakeResp_openai_gateway(
|
||||
payload={
|
||||
"id": "x",
|
||||
"model": model,
|
||||
"choices": [{"message": {"content": "fallback ok"}}],
|
||||
}
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_falls_back_on_a_context_length_error(local_db, monkeypatch):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeContextLengthClient_openai_gateway)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(source_model="ctx-backup-route", target_model="ctx-backup-target")
|
||||
)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(
|
||||
source_model="ctx-primary-route",
|
||||
target_model="ctx-primary-target",
|
||||
fallback_model="ctx-backup-route",
|
||||
)
|
||||
)
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = False
|
||||
cfg["gateway_max_retries"] = 0
|
||||
rt = svc.runtime()
|
||||
response = run_async(
|
||||
rt.handle_chat(
|
||||
{"model": "ctx-primary-route", "messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "context_length_fallback_success"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert rt._client.calls[0][1]["model"] == "ctx-primary-target"
|
||||
assert rt._client.calls[-1][1]["model"] == "ctx-backup-target"
|
||||
row = get_table("gateway_usage_ledger").find_one(
|
||||
owner_id="context_length_fallback_success"
|
||||
)
|
||||
assert row["success"] == 1
|
||||
assert row["fallback_used_route"] == "ctx-backup-route"
|
||||
finally:
|
||||
routing.model_store.remove("ctx-primary-route")
|
||||
routing.model_store.remove("ctx-backup-route")
|
||||
|
||||
|
||||
def test_chat_skips_a_too_small_primary_and_goes_straight_to_fallback(local_db, monkeypatch):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeContextLengthClient_openai_gateway)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(
|
||||
source_model="ctx-precheck-backup",
|
||||
target_model="ctx-backup-target",
|
||||
context_window=1_000_000,
|
||||
)
|
||||
)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(
|
||||
source_model="ctx-precheck-primary",
|
||||
target_model="ctx-primary-target",
|
||||
context_window=50,
|
||||
fallback_model="ctx-precheck-backup",
|
||||
)
|
||||
)
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = False
|
||||
cfg["gateway_max_retries"] = 0
|
||||
rt = svc.runtime()
|
||||
long_message = "word " * 2000
|
||||
response = run_async(
|
||||
rt.handle_chat(
|
||||
{
|
||||
"model": "ctx-precheck-primary",
|
||||
"messages": [{"role": "user", "content": long_message}],
|
||||
},
|
||||
cfg,
|
||||
("guest", "context_precheck_skips_primary"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Only the fallback was ever called - the primary was known too small.
|
||||
assert len(rt._client.calls) == 1
|
||||
assert rt._client.calls[0][1]["model"] == "ctx-backup-target"
|
||||
row = get_table("gateway_usage_ledger").find_one(
|
||||
owner_id="context_precheck_skips_primary"
|
||||
)
|
||||
assert row["fallback_used_route"] == "ctx-precheck-backup"
|
||||
finally:
|
||||
routing.model_store.remove("ctx-precheck-primary")
|
||||
routing.model_store.remove("ctx-precheck-backup")
|
||||
|
||||
|
||||
def test_chat_falls_back_via_molodetz_when_the_client_sends_an_unrouted_model_name(
|
||||
local_db, monkeypatch
|
||||
):
|
||||
@@ -1271,6 +1451,50 @@ def test_stream_records_ttft_and_inter_token_in_ledger(local_db, monkeypatch):
|
||||
assert row["inter_token_ms"] is not None and row["inter_token_ms"] >= 0.0
|
||||
|
||||
|
||||
class FakeClientReasoning_openai_gateway(FakeClient_openai_gateway):
|
||||
async def send(self, request, stream=False):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
body = request.json_body or {}
|
||||
return FakeResp_openai_gateway(
|
||||
payload={
|
||||
"id": "x",
|
||||
"model": body.get("model"),
|
||||
"choices": [{"message": {"content": "x", "reasoning": "aaaaa"}}],
|
||||
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_reasoning_delta_is_counted_as_a_content_chunk(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientReasoning_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_chat(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
},
|
||||
cfg,
|
||||
("guest", "ollama_reasoning_probe"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
|
||||
async def drain():
|
||||
async for _ in resp.body_iterator:
|
||||
pass
|
||||
|
||||
run_async(drain())
|
||||
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="ollama_reasoning_probe")
|
||||
assert row is not None
|
||||
assert row["inter_token_ms"] is not None and row["inter_token_ms"] >= 0.0
|
||||
|
||||
|
||||
def test_stream_client_disconnect_records_failure_and_closes_upstream(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
@@ -1552,6 +1776,153 @@ def test_no_upstream_model_header_keeps_our_own_model(local_db, monkeypatch):
|
||||
assert resp.headers["X-Gateway-Model"] == "deepseek-chat"
|
||||
|
||||
|
||||
class FakeEmbeddedErrorClient_openai_gateway:
|
||||
"""Mimics OpenRouter's documented behavior of answering 200 OK with the
|
||||
failure embedded in the JSON body (openrouter.ai/docs/api-reference/errors)
|
||||
instead of a non-2xx status."""
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
|
||||
def build_request(self, method, url, headers=None, json=None, content=None):
|
||||
return FakeRequest(method, url, json)
|
||||
|
||||
async def send(self, request, stream=False):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
body = request.json_body or {}
|
||||
model = body.get("model")
|
||||
if model == "primary-target":
|
||||
return FakeResp_openai_gateway(
|
||||
status=200,
|
||||
payload={"error": {"message": "no provider available", "code": 502}},
|
||||
)
|
||||
return FakeResp_openai_gateway(
|
||||
payload={
|
||||
"id": "x",
|
||||
"model": model,
|
||||
"choices": [{"message": {"content": "fallback ok"}}],
|
||||
}
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_falls_back_when_upstream_returns_200_with_an_embedded_error(
|
||||
local_db, monkeypatch
|
||||
):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
monkeypatch.setattr(
|
||||
gwmod.httpx, "AsyncClient", FakeEmbeddedErrorClient_openai_gateway
|
||||
)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(source_model="eb-backup-route", target_model="backup-target")
|
||||
)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(
|
||||
source_model="eb-primary-route",
|
||||
target_model="primary-target",
|
||||
fallback_model="eb-backup-route",
|
||||
)
|
||||
)
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = False
|
||||
cfg["gateway_max_retries"] = 0
|
||||
rt = svc.runtime()
|
||||
response = run_async(
|
||||
rt.handle_chat(
|
||||
{"model": "eb-primary-route", "messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "embedded_error_chat"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert rt._client.calls[0][1]["model"] == "primary-target"
|
||||
assert rt._client.calls[-1][1]["model"] == "backup-target"
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="embedded_error_chat")
|
||||
assert row is not None
|
||||
assert row["requested_model"] == "eb-primary-route"
|
||||
assert row["model"] == "backup-target"
|
||||
assert row["success"] == 1
|
||||
finally:
|
||||
routing.model_store.remove("eb-primary-route")
|
||||
routing.model_store.remove("eb-backup-route")
|
||||
|
||||
|
||||
class FakeAlwaysEmbeddedErrorClient_openai_gateway:
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
|
||||
def build_request(self, method, url, headers=None, json=None, content=None):
|
||||
return FakeRequest(method, url, json)
|
||||
|
||||
async def send(self, request, stream=False):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
return FakeResp_openai_gateway(
|
||||
status=200,
|
||||
payload={"error": {"message": "no provider available", "code": 502}},
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_reports_502_when_upstream_returns_200_with_an_embedded_error_and_no_fallback(
|
||||
local_db, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
gwmod.httpx, "AsyncClient", FakeAlwaysEmbeddedErrorClient_openai_gateway
|
||||
)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = False
|
||||
cfg["gateway_max_retries"] = 0
|
||||
rt = svc.runtime()
|
||||
response = run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "embedded_error_no_fallback"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert response.status_code == 502
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="embedded_error_no_fallback")
|
||||
assert row is not None
|
||||
assert row["success"] == 0
|
||||
assert row["error_category"] == "upstream_error"
|
||||
|
||||
|
||||
def test_ollama_dialect_sends_reasoning_effort_alongside_think(local_db, monkeypatch):
|
||||
# Ollama's native /api/chat honors a boolean `think`, but its
|
||||
# OpenAI-compatible /v1/chat/completions layer (what this gateway
|
||||
# actually calls) does not - it maps reasoning_effort/reasoning
|
||||
# instead (github.com/ollama/ollama issues #15288, #15293, #14820).
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_upstream_url"] = "http://127.0.0.1:11434/v1/chat/completions"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "ollama_reasoning_effort"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
body = rt._client.calls[-1][1]
|
||||
assert body["think"] is False
|
||||
assert body["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_hostile_upstream_model_header_is_ignored_end_to_end(local_db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gwmod.httpx, "AsyncClient", FakeClientHostileModelHeader_openai_gateway
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.openai_gateway import model_health
|
||||
|
||||
|
||||
def setup_function():
|
||||
model_health.reset()
|
||||
|
||||
|
||||
def test_speed_reward_is_neutral_without_data():
|
||||
assert model_health.speed_reward(None) == model_health.NEUTRAL_REWARD
|
||||
assert model_health.speed_reward(0) == model_health.NEUTRAL_REWARD
|
||||
|
||||
|
||||
def test_speed_reward_increases_with_throughput():
|
||||
slow = model_health.speed_reward(5)
|
||||
fast = model_health.speed_reward(50)
|
||||
assert 0 < slow < fast < 1
|
||||
|
||||
|
||||
def test_latency_reward_is_one_without_data():
|
||||
assert model_health.latency_reward(None) == 1.0
|
||||
assert model_health.latency_reward(0) == 1.0
|
||||
|
||||
|
||||
def test_latency_reward_decreases_with_latency():
|
||||
fast = model_health.latency_reward(500)
|
||||
slow = model_health.latency_reward(20000)
|
||||
assert 0 < slow < fast <= 1
|
||||
|
||||
|
||||
def test_untested_model_has_neutral_weight():
|
||||
health = model_health.ModelHealth()
|
||||
assert health.weight() == 0.5
|
||||
|
||||
|
||||
def test_record_outcome_success_improves_weight_over_failures():
|
||||
model_health.record_outcome("openrouter", "fast-model", True, latency_ms=200, tokens_per_second=80)
|
||||
model_health.record_outcome("openrouter", "slow-model", False)
|
||||
fast_weight = model_health.snapshot_for("openrouter", "fast-model")["weight"]
|
||||
slow_weight = model_health.snapshot_for("openrouter", "slow-model")["weight"]
|
||||
assert fast_weight > 0.5
|
||||
assert slow_weight < 0.5
|
||||
|
||||
|
||||
def test_repeated_failures_open_the_circuit_for_display_only():
|
||||
for _ in range(model_health.CIRCUIT_BREAKER_FAILURE_THRESHOLD):
|
||||
model_health.record_outcome("deepseek", "flaky-model", False)
|
||||
snapshot = model_health.snapshot_for("deepseek", "flaky-model")
|
||||
assert snapshot["circuit_open"] is True
|
||||
assert snapshot["consecutive_failures"] == model_health.CIRCUIT_BREAKER_FAILURE_THRESHOLD
|
||||
|
||||
|
||||
def test_success_resets_consecutive_failures_and_circuit():
|
||||
model_health.record_outcome("deepseek", "recovering-model", False)
|
||||
model_health.record_outcome("deepseek", "recovering-model", False)
|
||||
model_health.record_outcome("deepseek", "recovering-model", True, latency_ms=100, tokens_per_second=50)
|
||||
snapshot = model_health.snapshot_for("deepseek", "recovering-model")
|
||||
assert snapshot["consecutive_failures"] == 0
|
||||
assert snapshot["circuit_open"] is False
|
||||
|
||||
|
||||
def test_snapshot_for_unknown_model_is_none():
|
||||
assert model_health.snapshot_for("openrouter", "never-seen") is None
|
||||
|
||||
|
||||
def test_apply_history_accumulates_without_touching_circuit_state():
|
||||
model_health.apply_history("openrouter", "seeded-model", success_count=10, failure_count=2, total_reward=6.0)
|
||||
snapshot = model_health.snapshot_for("openrouter", "seeded-model")
|
||||
assert snapshot["success_count"] == 10
|
||||
assert snapshot["failure_count"] == 2
|
||||
assert snapshot["circuit_open"] is False
|
||||
|
||||
|
||||
def test_record_outcome_requires_a_model_name():
|
||||
model_health.record_outcome("openrouter", "", True, latency_ms=100)
|
||||
assert model_health.snapshot_all() == {}
|
||||
|
||||
|
||||
def test_snapshot_all_keys_are_provider_colon_model():
|
||||
model_health.record_outcome("groq", "llama-3", True)
|
||||
keys = model_health.snapshot_all().keys()
|
||||
assert "groq:llama-3" in keys
|
||||
@@ -0,0 +1,37 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.openai_gateway import model_stats_query as msq
|
||||
|
||||
|
||||
def test_bucket_seconds_has_a_floor():
|
||||
assert msq._bucket_seconds(60) == 60
|
||||
assert msq._bucket_seconds(3600) == 60
|
||||
|
||||
|
||||
def test_bucket_seconds_scales_with_range():
|
||||
week = 7 * 86400
|
||||
assert msq._bucket_seconds(week) == week // 120
|
||||
|
||||
|
||||
def test_histogram_places_values_in_the_right_bucket():
|
||||
edges = (10, 20)
|
||||
labels = ("low", "mid", "high")
|
||||
result = msq._histogram([5, 15, 25, 9.999, 20], edges, labels)
|
||||
counts = {row["label"]: row["count"] for row in result}
|
||||
assert counts == {"low": 2, "mid": 1, "high": 2}
|
||||
|
||||
|
||||
def test_histogram_empty_input_is_all_zero():
|
||||
result = msq._histogram([], (200, 500), ("a", "b", "c"))
|
||||
assert all(row["count"] == 0 for row in result)
|
||||
|
||||
|
||||
def test_resolve_range_rejects_unknown_key():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
msq._resolve_range("not-a-real-range")
|
||||
|
||||
|
||||
def test_resolve_range_known_key():
|
||||
assert msq._resolve_range("24h") == msq.RANGE_SECONDS["24h"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.openai_gateway import opencode_zen
|
||||
|
||||
|
||||
def test_opencode_id_has_the_expected_shape():
|
||||
value = opencode_zen._opencode_id()
|
||||
assert len(value) == opencode_zen._OPCODE_ID_LENGTH
|
||||
assert all(ch in opencode_zen._OPCODE_ID_ALPHABET for ch in value)
|
||||
|
||||
|
||||
def test_opencode_ids_are_not_repeated():
|
||||
ids = {opencode_zen._opencode_id() for _ in range(50)}
|
||||
assert len(ids) == 50
|
||||
|
||||
|
||||
def test_session_id_is_stable_across_calls():
|
||||
first = opencode_zen.opencode_impersonation_headers()
|
||||
second = opencode_zen.opencode_impersonation_headers()
|
||||
assert first["x-opencode-session"] == second["x-opencode-session"]
|
||||
assert first["x-opencode-session"].startswith("ses_")
|
||||
|
||||
|
||||
def test_request_id_is_fresh_every_call():
|
||||
first = opencode_zen.opencode_impersonation_headers()
|
||||
second = opencode_zen.opencode_impersonation_headers()
|
||||
assert first["x-opencode-request"] != second["x-opencode-request"]
|
||||
assert first["x-opencode-request"].startswith("msg_")
|
||||
|
||||
|
||||
def test_headers_include_the_expected_client_identity_fields():
|
||||
headers = opencode_zen.opencode_impersonation_headers()
|
||||
assert set(headers) == {
|
||||
"User-Agent",
|
||||
"x-opencode-client",
|
||||
"x-opencode-project",
|
||||
"x-opencode-session",
|
||||
"x-opencode-request",
|
||||
}
|
||||
assert headers["x-opencode-project"] == "global"
|
||||
@@ -0,0 +1,76 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.openai_gateway.reliability import (
|
||||
_retry_after_seconds,
|
||||
retry_send,
|
||||
)
|
||||
|
||||
|
||||
class FakeHeaders_reliability(dict):
|
||||
def get(self, key, default=None):
|
||||
return super().get(key.lower(), default)
|
||||
|
||||
|
||||
class FakeResp_reliability:
|
||||
def __init__(self, status_code, retry_after=None):
|
||||
self.status_code = status_code
|
||||
headers = FakeHeaders_reliability()
|
||||
if retry_after is not None:
|
||||
headers["retry-after"] = retry_after
|
||||
self.headers = headers
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_retry_after_seconds_parses_numeric_header():
|
||||
assert _retry_after_seconds(FakeResp_reliability(429, "2")) == 2.0
|
||||
|
||||
|
||||
def test_retry_after_seconds_absent_returns_none():
|
||||
assert _retry_after_seconds(FakeResp_reliability(429)) is None
|
||||
|
||||
|
||||
def test_retry_after_seconds_is_capped():
|
||||
assert _retry_after_seconds(FakeResp_reliability(429, "99999")) == 30.0
|
||||
|
||||
|
||||
def test_retry_after_seconds_ignores_garbage():
|
||||
assert _retry_after_seconds(FakeResp_reliability(429, "not-a-number-or-date")) is None
|
||||
|
||||
|
||||
def test_retry_send_retries_429_and_honors_retry_after():
|
||||
responses = [FakeResp_reliability(429, "0.01"), FakeResp_reliability(200)]
|
||||
calls = []
|
||||
|
||||
async def do_call():
|
||||
calls.append(time.monotonic())
|
||||
return responses.pop(0)
|
||||
|
||||
sem = asyncio.Semaphore(1)
|
||||
resp, exc, attempts, queue_wait_ms = run_async(
|
||||
retry_send(do_call, sem, max_retries=2, backoff_ms=5000)
|
||||
)
|
||||
assert exc is None
|
||||
assert resp.status_code == 200
|
||||
assert attempts == 2
|
||||
# The 429 branch waits on the short Retry-After (0.01s), never the
|
||||
# much larger fixed 5000ms*attempt linear backoff it would otherwise use.
|
||||
assert calls[1] - calls[0] < 1.0
|
||||
|
||||
|
||||
def test_retry_send_gives_up_after_max_retries_on_429():
|
||||
async def do_call():
|
||||
return FakeResp_reliability(429, "0.001")
|
||||
|
||||
sem = asyncio.Semaphore(1)
|
||||
resp, exc, attempts, queue_wait_ms = run_async(
|
||||
retry_send(do_call, sem, max_retries=1, backoff_ms=1)
|
||||
)
|
||||
assert exc is None
|
||||
assert resp.status_code == 429
|
||||
assert attempts == 2
|
||||
@@ -5,6 +5,45 @@ from pydantic import ValidationError
|
||||
|
||||
from devplacepy.services.openai_gateway import routing as r
|
||||
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
class _FakeModelsResponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeModelsClient:
|
||||
def __init__(self, status_code, payload=None, error=None):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
self.error = error
|
||||
self.requested_url = None
|
||||
self.headers = None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def get(self, url):
|
||||
self.requested_url = url
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return _FakeModelsResponse(self.status_code, self.payload)
|
||||
|
||||
|
||||
def _patch_stealth_client(monkeypatch, fake_client):
|
||||
def _factory(**kwargs):
|
||||
fake_client.headers = kwargs.get("headers")
|
||||
return fake_client
|
||||
|
||||
monkeypatch.setattr("devplacepy.stealth.stealth_async_client", _factory)
|
||||
|
||||
|
||||
def _cleanup(providers, models):
|
||||
@@ -262,6 +301,46 @@ def test_blank_provider_uses_default_upstream(local_db):
|
||||
_cleanup([], ["ut-default"])
|
||||
|
||||
|
||||
def test_provider_with_a_client_profile_gets_extra_headers(local_db):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(
|
||||
name="zen",
|
||||
base_url="https://opencode.ai/zen/v1/chat/completions",
|
||||
api_key="public",
|
||||
client_profile="opencode",
|
||||
)
|
||||
)
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(source_model="ut-zen", provider="zen", target_model="kimi-k3")
|
||||
)
|
||||
try:
|
||||
overlay = r.chat_overlay("ut-zen", {})
|
||||
headers = overlay["gateway_extra_request_headers"]
|
||||
assert headers["x-opencode-client"]
|
||||
assert "User-Agent" in headers
|
||||
finally:
|
||||
_cleanup(["zen"], ["ut-zen"])
|
||||
|
||||
|
||||
def test_provider_without_a_client_profile_gets_no_extra_headers(local_db):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(name="plainprov", base_url="https://up.example/v1/chat/completions")
|
||||
)
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(source_model="ut-plain", provider="plainprov", target_model="vendor/x")
|
||||
)
|
||||
try:
|
||||
overlay = r.chat_overlay("ut-plain", {})
|
||||
assert "gateway_extra_request_headers" not in overlay
|
||||
finally:
|
||||
_cleanup(["plainprov"], ["ut-plain"])
|
||||
|
||||
|
||||
def test_client_profile_rejects_unknown_value():
|
||||
with pytest.raises(ValidationError):
|
||||
r.ProviderIn(name="badprofile", client_profile="not-a-real-profile")
|
||||
|
||||
|
||||
def test_tier2_and_off_peak_fields_propagate_through_overlay(local_db):
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
@@ -381,3 +460,112 @@ def test_seed_publishes_molodetz_aliases(local_db):
|
||||
finally:
|
||||
r.model_store.remove("molodetz")
|
||||
r.model_store.remove("molodetz-pro")
|
||||
|
||||
|
||||
def test_models_url_from_base_swaps_chat_completions():
|
||||
assert (
|
||||
r._models_url_from_base("https://x.example/v1/chat/completions")
|
||||
== "https://x.example/v1/models"
|
||||
)
|
||||
|
||||
|
||||
def test_models_url_from_base_appends_when_no_chat_completions_suffix():
|
||||
assert r._models_url_from_base("https://x.example/v1") == "https://x.example/v1/models"
|
||||
assert r._models_url_from_base("https://x.example/v1/") == "https://x.example/v1/models"
|
||||
|
||||
|
||||
def test_models_url_from_base_blank_is_blank():
|
||||
assert r._models_url_from_base("") == ""
|
||||
assert r._models_url_from_base(" ") == ""
|
||||
|
||||
|
||||
def test_fetch_provider_models_returns_ids_on_success(local_db, monkeypatch):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(
|
||||
name="probeprov",
|
||||
base_url="https://x.example/v1/chat/completions",
|
||||
api_key="sk-probe",
|
||||
)
|
||||
)
|
||||
fake_client = _FakeModelsClient(200, {"data": [{"id": "vendor/a"}, {"id": "vendor/b"}]})
|
||||
_patch_stealth_client(monkeypatch, fake_client)
|
||||
try:
|
||||
models = run_async(r.fetch_provider_models("probeprov"))
|
||||
assert models == ["vendor/a", "vendor/b"]
|
||||
assert fake_client.requested_url == "https://x.example/v1/models"
|
||||
assert fake_client.headers == {"authorization": "Bearer sk-probe"}
|
||||
finally:
|
||||
r.provider_store.remove("probeprov")
|
||||
|
||||
|
||||
def test_fetch_provider_models_uses_default_provider_when_blank(local_db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_default_provider_credentials",
|
||||
lambda: ("https://default.example/v1/chat/completions", "sk-default"),
|
||||
)
|
||||
fake_client = _FakeModelsClient(200, {"data": [{"id": "default/model"}]})
|
||||
_patch_stealth_client(monkeypatch, fake_client)
|
||||
models = run_async(r.fetch_provider_models(""))
|
||||
assert models == ["default/model"]
|
||||
assert fake_client.requested_url == "https://default.example/v1/models"
|
||||
|
||||
|
||||
def test_fetch_provider_models_none_for_unknown_provider(local_db):
|
||||
assert run_async(r.fetch_provider_models("no-such-provider")) is None
|
||||
|
||||
|
||||
def test_fetch_provider_models_none_on_non_200(local_db, monkeypatch):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(name="probeprov404", base_url="https://x.example/v1/chat/completions")
|
||||
)
|
||||
fake_client = _FakeModelsClient(404, {})
|
||||
_patch_stealth_client(monkeypatch, fake_client)
|
||||
try:
|
||||
assert run_async(r.fetch_provider_models("probeprov404")) is None
|
||||
finally:
|
||||
r.provider_store.remove("probeprov404")
|
||||
|
||||
|
||||
def test_fetch_provider_models_none_on_malformed_payload(local_db, monkeypatch):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(name="probeprovbad", base_url="https://x.example/v1/chat/completions")
|
||||
)
|
||||
fake_client = _FakeModelsClient(200, {"not_data": []})
|
||||
_patch_stealth_client(monkeypatch, fake_client)
|
||||
try:
|
||||
assert run_async(r.fetch_provider_models("probeprovbad")) is None
|
||||
finally:
|
||||
r.provider_store.remove("probeprovbad")
|
||||
|
||||
|
||||
def test_fetch_provider_models_none_on_empty_list(local_db, monkeypatch):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(name="probeprovempty", base_url="https://x.example/v1/chat/completions")
|
||||
)
|
||||
fake_client = _FakeModelsClient(200, {"data": []})
|
||||
_patch_stealth_client(monkeypatch, fake_client)
|
||||
try:
|
||||
assert run_async(r.fetch_provider_models("probeprovempty")) is None
|
||||
finally:
|
||||
r.provider_store.remove("probeprovempty")
|
||||
|
||||
|
||||
def test_fetch_provider_models_none_on_network_error(local_db, monkeypatch):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(name="probeprovdown", base_url="https://x.example/v1/chat/completions")
|
||||
)
|
||||
fake_client = _FakeModelsClient(200, error=RuntimeError("connection refused"))
|
||||
_patch_stealth_client(monkeypatch, fake_client)
|
||||
try:
|
||||
assert run_async(r.fetch_provider_models("probeprovdown")) is None
|
||||
finally:
|
||||
r.provider_store.remove("probeprovdown")
|
||||
|
||||
|
||||
def test_fetch_provider_models_none_when_provider_has_no_base_url(local_db):
|
||||
r.provider_store.set(r.ProviderIn(name="probeprovnourl", base_url=""))
|
||||
try:
|
||||
assert run_async(r.fetch_provider_models("probeprovnourl")) is None
|
||||
finally:
|
||||
r.provider_store.remove("probeprovnourl")
|
||||
|
||||
@@ -38,6 +38,15 @@ def test_upstream_capabilities_explicit_dialect_override():
|
||||
assert caps.supports_stream_options is False
|
||||
|
||||
|
||||
def test_upstream_capabilities_ollama_stream_usage_opt_in():
|
||||
caps = upstream_capabilities(
|
||||
"http://127.0.0.1:11434/api/chat", ollama_stream_usage=True
|
||||
)
|
||||
assert caps.dialect == "ollama"
|
||||
assert caps.supports_stream_options is True
|
||||
assert caps.supports_stream_usage is True
|
||||
|
||||
|
||||
def test_apply_thinking_respects_explicit_dialect_override():
|
||||
without = apply_thinking({}, "https://ai.example.com/v1/chat/completions")
|
||||
assert without["thinking"] == {"type": "disabled"}
|
||||
@@ -91,6 +100,7 @@ def test_default_disables_openrouter_and_ollama():
|
||||
assert "thinking" not in openrouter
|
||||
ollama = apply_thinking({}, "http://127.0.0.1:11434/api/chat")
|
||||
assert ollama["think"] is False
|
||||
assert ollama["reasoning_effort"] == "none"
|
||||
assert "thinking" not in ollama
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user