feat: add provider and model routing tables, admin UI, and audit category for gateway

Implement the multi-provider routing system for the OpenAI gateway, including two new database tables (`gateway_providers`, `gateway_models`) ensured at init, a new admin page at `/admin/gateway` with full CRUD for providers and model routes, and a `"gateway"` audit category mapped to `"ai"`. The routing layer sits transparently on top of the existing single-provider default path: unmatched model names fall through unchanged, while matched routes forward to the configured provider with their own pricing economy, vision model, and context window. Cross-worker cache invalidation uses a shared `_ROUTING_CACHE` bumped via `"gateway_routing"` cache version.
This commit is contained in:
2026-06-16 22:11:14 +00:00
parent c100b4b692
commit 7bc67662fa
21 changed files with 1401 additions and 20 deletions
+4
View File
@@ -650,6 +650,10 @@ def init_db():
from devplacepy.services.backup import store as backup_store
backup_store.ensure_tables()
from devplacepy.services.openai_gateway import routing as gateway_routing
gateway_routing.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"])
+25 -5
View File
@@ -3991,6 +3991,25 @@ The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`.
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
## Model routing and providers
On top of the single default upstream above, an administrator can register additional named
**providers** and map any number of requested **model names** onto them, so one gateway can front
many models across many backends. A model route binds a source model name (what a client sends) to a
target provider and upstream model, and carries:
- its **own pricing economy** (input, output, and cache-hit / cache-miss prices per million tokens),
used to compute that call's cost when the upstream returns no native cost;
- an optional **vision model**, which turns on the image-to-text merge for that route (so a text-only
model can answer about images);
- an optional **context window** used for the context-utilization header.
Resolution is transparent to clients: when the requested `model` matches an active route, the gateway
forwards to that route's provider and target model and meters the call against the route's economy.
When it matches no route, the request falls through to the default upstream unchanged (so `molodetz`,
`molodetz~embed`, and any existing client keep working exactly as before). Providers and routes are
managed by administrators on the **Gateway** page (`/admin/gateway`).
## Per-call cost and usage headers
Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
@@ -4021,9 +4040,9 @@ and dollar cost directly from the response with no extra request:
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |
Dollar costs use the upstream's native `cost` field when it returns one
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million input and output
prices configured on the `openai` service. The one denied path that makes no upstream call
(embeddings disabled) returns no usage headers.
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched
model route, falling back to the prices configured on the `openai` service when no route matches. The
one denied path that makes no upstream call (embeddings disabled) returns no usage headers.
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).
@@ -4048,7 +4067,7 @@ for signing DevPlace's own requests.
"string",
False,
"gpt-4o-mini",
"Model id; the gateway may override it.",
"Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it uses the default upstream model.",
),
field(
"messages",
@@ -4070,6 +4089,7 @@ for signing DevPlace's own requests.
notes=[
"Returns `503` when the gateway service is not running.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
"If `model` matches a configured model route it is forwarded to that route's provider, upstream model, and per-model pricing (with an optional vision model); otherwise it falls through to the default upstream (see Model routing and providers above).",
],
),
endpoint(
@@ -4087,7 +4107,7 @@ for signing DevPlace's own requests.
"string",
False,
"molodetz~embed",
"Embedding model id; the gateway maps molodetz~embed to the configured model.",
"Embedding model id; the gateway maps molodetz~embed to the configured model, or to a matching embed model route's provider and target model.",
),
field(
"input",
+2
View File
@@ -7,6 +7,7 @@ from devplacepy.routers.admin import (
backups,
bots,
containers,
gateway_configs,
issues,
media,
news,
@@ -30,5 +31,6 @@ router.include_router(issues.router)
router.include_router(auditlog.router)
router.include_router(backups.router)
router.include_router(bots.router)
router.include_router(gateway_configs.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
+182
View File
@@ -0,0 +1,182 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import ValidationError
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import routing
from devplacepy.templating import templates
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _default_provider_summary() -> dict:
svc = service_manager.get_service("openai")
cfg = svc.get_config() if svc is not None else {}
return {
"base_url": cfg.get("gateway_upstream_url", ""),
"model": cfg.get("gateway_model", ""),
"embed_url": cfg.get("gateway_embed_url", ""),
"embed_model": cfg.get("gateway_embed_model", ""),
"vision_url": cfg.get("gateway_vision_url", ""),
"vision_model": cfg.get("gateway_vision_model", ""),
}
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)
async def _payload(request: Request) -> dict:
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
form = await request.form()
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)],
)
return templates.TemplateResponse(
request,
"admin_gateway.html",
{
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "gateway",
"default_provider": _default_provider_summary(),
},
)
@router.get("/gateway/providers")
async def list_providers(request: Request):
require_admin(request)
return JSONResponse(
{
"providers": routing.provider_store.list(),
"default": _default_provider_summary(),
"count": routing.provider_store.count(),
}
)
@router.post("/gateway/providers")
async def save_provider(request: Request):
admin = require_admin(request)
body = await _payload(request)
try:
payload = routing.ProviderIn(**body)
except ValidationError as exc:
return _validation_error(exc)
saved = routing.provider_store.set(payload)
audit.record(
request,
"gateway.provider.update",
user=admin,
target_type="gateway_provider",
target_uid=payload.name,
target_label=payload.name,
summary=f"admin {admin['username']} saved gateway provider {payload.name}",
)
return JSONResponse({"ok": True, "provider": saved})
@router.delete("/gateway/providers/{name}")
async def delete_provider(request: Request, name: str):
admin = require_admin(request)
existed = routing.provider_store.remove(name)
if not existed:
return JSONResponse({"ok": False, "error": "Provider not found"}, status_code=404)
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 JSONResponse({"ok": True})
@router.get("/gateway/models")
async def list_models(request: Request):
require_admin(request)
return JSONResponse(
{
"models": routing.model_store.list(),
"providers": routing.provider_store.names(),
"count": routing.model_store.count(),
}
)
@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)
saved = routing.model_store.set(payload)
audit.record(
request,
"gateway.model.update",
user=admin,
target_type="gateway_model",
target_uid=payload.source_model,
target_label=f"{payload.source_model} -> {payload.target_model}",
summary=(
f"admin {admin['username']} saved gateway model route "
f"{payload.source_model} -> {payload.target_model}"
),
)
return JSONResponse({"ok": True, "model": saved})
@router.delete("/gateway/models/{source_model}")
async def delete_model(request: Request, source_model: str):
admin = require_admin(request)
existed = routing.model_store.remove(source_model)
if not existed:
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})
+1
View File
@@ -26,6 +26,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"news": "news",
"admin": "admin",
"service": "service",
"gateway": "ai",
"container": "container",
"proxy": "ingress",
"seo": "tools",
@@ -1696,6 +1696,102 @@ ACTIONS: tuple[Action, ...] = (
),
requires_admin=True,
),
Action(
name="gateway_providers",
method="GET",
path="/admin/gateway/providers",
summary="List OpenAI gateway providers (admin only)",
description=(
"Returns JSON: the named upstream providers used by model routes, plus the implicit "
"'default' provider read from the gateway service config."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_provider_set",
method="POST",
path="/admin/gateway/providers",
summary="Create or update a gateway provider (admin only)",
description=(
"Adds or updates a named upstream provider. base_url is the OpenAI-compatible "
"chat-completions endpoint; the embeddings endpoint is derived from it."
),
handler="http",
requires_admin=True,
params=(
body("name", "Provider name (letters, numbers, hyphen, underscore).", required=True),
body("base_url", "Chat-completions endpoint URL."),
body("api_key", "Upstream API key."),
Param(
name="is_active",
location="body",
description="Whether the provider is active ('1' or '0').",
required=False,
type="boolean",
),
),
),
Action(
name="gateway_provider_delete",
method="DELETE",
path="/admin/gateway/providers/{name}",
summary="Delete a gateway provider (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("name", "Provider name."), confirm()),
),
Action(
name="gateway_models",
method="GET",
path="/admin/gateway/models",
summary="List OpenAI gateway model routes (admin only)",
description=(
"Returns JSON: every source-model route with its provider, target model, kind "
"(chat/embed), optional vision model, context window, and per-model pricing economy."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_model_set",
method="POST",
path="/admin/gateway/models",
summary="Create or update a gateway model route (admin only)",
description=(
"Maps a requested source_model onto a provider + target_model, each with its own "
"pricing. kind is 'chat' or 'embed'. A vision_model adds image-to-text augmentation "
"for chat routes. Prices are USD per 1,000,000 tokens; chat uses cache-hit/cache-miss/"
"output, embeddings use input, vision uses input/output."
),
handler="http",
requires_admin=True,
params=(
body("source_model", "Model name clients request.", required=True),
body("provider", "Provider name, or blank for the default upstream."),
body("target_model", "Model name sent upstream.", required=True),
body("kind", "Route kind: 'chat' or 'embed'."),
body("vision_provider", "Provider for image description, or blank for the route provider."),
body("vision_model", "Vision model name (blank disables the merge)."),
Param(name="context_window", location="body", description="Max context tokens (0 = unknown).", required=False, type="integer"),
Param(name="price_cache_hit_per_m", location="body", description="USD per 1M cache-hit input tokens.", required=False, type="number"),
Param(name="price_cache_miss_per_m", location="body", description="USD per 1M cache-miss input tokens.", required=False, type="number"),
Param(name="price_output_per_m", location="body", description="USD per 1M output tokens.", required=False, type="number"),
Param(name="price_input_per_m", location="body", description="USD per 1M input tokens (embed/vision).", required=False, type="number"),
Param(name="is_active", location="body", description="Whether the route is active ('1' or '0').", required=False, type="boolean"),
),
),
Action(
name="gateway_model_delete",
method="DELETE",
path="/admin/gateway/models/{source_model}",
summary="Delete a gateway model route (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("source_model", "Source model name."), confirm()),
),
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
@@ -56,6 +56,8 @@ CONFIRM_REQUIRED = {
"db_insert_row",
"db_update_row",
"db_delete_row",
"gateway_provider_delete",
"gateway_model_delete",
}
CONDITIONAL_CONFIRM = {
@@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
from devplacepy import stealth
from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send
from devplacepy.services.openai_gateway.routing import chat_overlay, embed_overlay
from devplacepy.services.openai_gateway.system_message import apply_system_directives
from devplacepy.services.openai_gateway.usage import (
GatewayUsageLedger,
@@ -213,6 +214,10 @@ class GatewayRuntime:
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
):
log = log or (lambda message: None)
overlay = chat_overlay(body.get("model"), cfg)
if overlay:
cfg = {**cfg, **overlay}
log(f"routed model {body.get('model')!r} -> {cfg['gateway_model']!r}")
client, sem = self._ensure(cfg)
pricing = pricing_from_cfg(cfg)
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
@@ -365,6 +370,10 @@ class GatewayRuntime:
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
):
log = log or (lambda message: None)
overlay = embed_overlay(body.get("model"), cfg)
if overlay:
cfg = {**cfg, **overlay}
log(f"routed embed model {body.get('model')!r} -> {cfg['gateway_embed_model']!r}")
if not cfg["gateway_embed_enabled"]:
from devplacepy.services.audit import record as audit
from devplacepy.services.openai_gateway.usage import audit_actor_for
@@ -0,0 +1,380 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from devplacepy.database import (
bump_cache_version,
db,
get_table,
sync_local_cache,
)
logger = logging.getLogger(__name__)
PROVIDERS_TABLE = "gateway_providers"
MODELS_TABLE = "gateway_models"
CACHE_NAME = "gateway_routing"
KINDS = ("chat", "embed")
_ROUTING_CACHE: dict = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _as_bool(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
return str(value).strip() in ("1", "true", "True", "yes", "on")
def _embed_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")] + "/embeddings"
return base_url
def ensure_tables() -> None:
db.query(
"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)"
)
db.query(
"CREATE TABLE IF NOT EXISTS "
+ MODELS_TABLE
+ " (id INTEGER PRIMARY KEY, source_model TEXT, provider TEXT, "
"target_model TEXT, kind TEXT DEFAULT 'chat', vision_provider TEXT, "
"vision_model TEXT, context_window INTEGER DEFAULT 0, "
"price_cache_hit_per_m REAL DEFAULT 0, price_cache_miss_per_m REAL DEFAULT 0, "
"price_output_per_m REAL DEFAULT 0, price_input_per_m REAL DEFAULT 0, "
"is_active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT)"
)
try:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_providers_name ON "
+ PROVIDERS_TABLE
+ " (name)"
)
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_models_source ON "
+ MODELS_TABLE
+ " (source_model)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_gateway_models_kind ON "
+ MODELS_TABLE
+ " (kind)"
)
except Exception as exc:
logger.warning("gateway routing index creation failed: %s", exc)
class ProviderIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
base_url: str = Field(default="", max_length=500)
api_key: str = Field(default="", max_length=400)
is_active: bool = True
@field_validator("name")
@classmethod
def _clean_name(cls, value: str) -> str:
value = value.strip().lower()
if not value:
raise ValueError("Provider name is required")
if not all(c.isalnum() or c in "-_" for c in value):
raise ValueError("Provider name allows letters, numbers, hyphen, underscore")
return value
@field_validator("base_url")
@classmethod
def _clean_url(cls, value: str) -> str:
value = (value or "").strip()
if value and not (value.startswith("http://") or value.startswith("https://")):
raise ValueError("Base URL must be a http(s) URL")
return value
class ModelRouteIn(BaseModel):
source_model: str = Field(min_length=1, max_length=128)
provider: str = Field(default="", max_length=64)
target_model: str = Field(min_length=1, max_length=128)
kind: str = "chat"
vision_provider: str = Field(default="", max_length=64)
vision_model: str = Field(default="", max_length=128)
context_window: int = Field(default=0, ge=0, le=100_000_000)
price_cache_hit_per_m: float = Field(default=0.0, ge=0)
price_cache_miss_per_m: float = Field(default=0.0, ge=0)
price_output_per_m: float = Field(default=0.0, ge=0)
price_input_per_m: float = Field(default=0.0, ge=0)
is_active: bool = True
@field_validator("source_model", "target_model")
@classmethod
def _clean_model(cls, value: str) -> str:
value = (value or "").strip()
if not value:
raise ValueError("Model name is required")
return value
@field_validator("provider", "vision_provider", "vision_model")
@classmethod
def _strip(cls, value: str) -> str:
return (value or "").strip()
@field_validator("kind")
@classmethod
def _clean_kind(cls, value: str) -> str:
value = (value or "chat").strip().lower()
if value not in KINDS:
raise ValueError("Kind must be 'chat' or 'embed'")
return value
@dataclass(frozen=True)
class ModelRoute:
source_model: str
provider: str
target_model: str
kind: str
vision_provider: str
vision_model: str
context_window: int
price_cache_hit_per_m: float
price_cache_miss_per_m: float
price_output_per_m: float
price_input_per_m: float
is_active: bool
def _route_from_row(row: dict) -> ModelRoute:
return ModelRoute(
source_model=str(row.get("source_model") or ""),
provider=str(row.get("provider") or ""),
target_model=str(row.get("target_model") or ""),
kind=str(row.get("kind") or "chat"),
vision_provider=str(row.get("vision_provider") or ""),
vision_model=str(row.get("vision_model") or ""),
context_window=int(row.get("context_window") or 0),
price_cache_hit_per_m=float(row.get("price_cache_hit_per_m") or 0.0),
price_cache_miss_per_m=float(row.get("price_cache_miss_per_m") or 0.0),
price_output_per_m=float(row.get("price_output_per_m") or 0.0),
price_input_per_m=float(row.get("price_input_per_m") or 0.0),
is_active=_as_bool(row.get("is_active", 1)),
)
def _load() -> dict:
sync_local_cache(CACHE_NAME, _ROUTING_CACHE)
if "providers" not in _ROUTING_CACHE:
providers: dict = {}
models: dict = {}
try:
if PROVIDERS_TABLE in db.tables:
for row in get_table(PROVIDERS_TABLE).all():
name = str(row.get("name") or "").strip().lower()
if name:
providers[name] = {
"name": name,
"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)),
}
if MODELS_TABLE in db.tables:
for row in get_table(MODELS_TABLE).all():
source = str(row.get("source_model") or "").strip()
if source:
models[source] = _route_from_row(row)
except Exception as exc:
logger.warning("gateway routing load failed: %s", exc)
_ROUTING_CACHE["providers"] = providers
_ROUTING_CACHE["models"] = models
return _ROUTING_CACHE
class ProviderStore:
def list(self) -> list[dict]:
return sorted(_load()["providers"].values(), key=lambda p: p["name"])
def get(self, name: str) -> Optional[dict]:
if not name:
return None
return _load()["providers"].get(name.strip().lower())
def names(self) -> list[str]:
return sorted(_load()["providers"].keys())
def count(self) -> int:
return len(_load()["providers"])
def set(self, payload: ProviderIn) -> dict:
ensure_tables()
table = get_table(PROVIDERS_TABLE)
existing = table.find_one(name=payload.name)
record = {
"name": payload.name,
"base_url": payload.base_url,
"api_key": payload.api_key,
"is_active": 1 if payload.is_active else 0,
"updated_at": _now(),
}
if existing:
table.update({**record, "id": existing["id"]}, ["id"])
else:
record["created_at"] = _now()
table.insert(record)
bump_cache_version(CACHE_NAME)
_ROUTING_CACHE.clear()
return self.get(payload.name) or record
def remove(self, name: str) -> bool:
name = (name or "").strip().lower()
if not name or PROVIDERS_TABLE not in db.tables:
return False
removed = int(get_table(PROVIDERS_TABLE).delete(name=name))
if removed:
bump_cache_version(CACHE_NAME)
_ROUTING_CACHE.clear()
return bool(removed)
class ModelStore:
def list(self) -> list[dict]:
rows = []
for route in _load()["models"].values():
rows.append(route.__dict__.copy())
return sorted(rows, key=lambda r: r["source_model"])
def get(self, source_model: str) -> Optional[ModelRoute]:
if not source_model:
return None
return _load()["models"].get(source_model.strip())
def resolve(self, source_model: Optional[str], kind: str) -> Optional[ModelRoute]:
if not source_model:
return None
route = _load()["models"].get(source_model.strip())
if route is None or not route.is_active or route.kind != kind:
return None
return route
def count(self) -> int:
return len(_load()["models"])
def set(self, payload: ModelRouteIn) -> dict:
ensure_tables()
table = get_table(MODELS_TABLE)
existing = table.find_one(source_model=payload.source_model)
record = {
"source_model": payload.source_model,
"provider": payload.provider,
"target_model": payload.target_model,
"kind": payload.kind,
"vision_provider": payload.vision_provider,
"vision_model": payload.vision_model,
"context_window": payload.context_window,
"price_cache_hit_per_m": payload.price_cache_hit_per_m,
"price_cache_miss_per_m": payload.price_cache_miss_per_m,
"price_output_per_m": payload.price_output_per_m,
"price_input_per_m": payload.price_input_per_m,
"is_active": 1 if payload.is_active else 0,
"updated_at": _now(),
}
if existing:
table.update({**record, "id": existing["id"]}, ["id"])
else:
record["created_at"] = _now()
table.insert(record)
bump_cache_version(CACHE_NAME)
_ROUTING_CACHE.clear()
route = self.get(payload.source_model)
return route.__dict__.copy() if route else record
def remove(self, source_model: str) -> bool:
source_model = (source_model or "").strip()
if not source_model or MODELS_TABLE not in db.tables:
return False
removed = int(get_table(MODELS_TABLE).delete(source_model=source_model))
if removed:
bump_cache_version(CACHE_NAME)
_ROUTING_CACHE.clear()
return bool(removed)
provider_store = ProviderStore()
model_store = ModelStore()
def _provider_overlay(name: str, base_key: str, url_key: str, overlay: dict) -> None:
provider = provider_store.get(name)
if provider is None:
return
if provider.get("base_url"):
overlay[url_key] = provider["base_url"]
if provider.get("api_key"):
overlay[base_key] = provider["api_key"]
def chat_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dict]:
route = model_store.resolve(requested_model, "chat")
if route is None:
return None
overlay: dict = {
"gateway_force_model": True,
"gateway_model": route.target_model,
"gateway_price_cache_hit_per_m": route.price_cache_hit_per_m,
"gateway_price_cache_miss_per_m": route.price_cache_miss_per_m,
"gateway_price_output_per_m": route.price_output_per_m,
}
if route.provider:
_provider_overlay(
route.provider, "gateway_api_key", "gateway_upstream_url", overlay
)
if route.context_window:
from devplacepy.services.openai_gateway.usage import parse_context_map
context_map = parse_context_map(base_cfg.get("gateway_model_context_map"))
context_map[route.target_model] = route.context_window
overlay["gateway_model_context_map"] = context_map
if route.vision_model:
overlay["gateway_vision_enabled"] = True
overlay["gateway_vision_model"] = route.vision_model
overlay["gateway_vision_price_input_per_m"] = route.price_input_per_m
vision_provider = route.vision_provider or route.provider
if vision_provider:
_provider_overlay(
vision_provider, "gateway_vision_key", "gateway_vision_url", overlay
)
return overlay
def embed_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dict]:
route = model_store.resolve(requested_model, "embed")
if route is None:
return None
overlay: dict = {
"gateway_force_model": True,
"gateway_embed_model": route.target_model,
"gateway_embed_price_input_per_m": route.price_input_per_m,
}
provider = provider_store.get(route.provider) if route.provider else None
if provider:
if provider.get("base_url"):
overlay["gateway_embed_url"] = _embed_url_from_base(provider["base_url"])
if provider.get("api_key"):
overlay["gateway_embed_key"] = provider["api_key"]
return overlay
+23
View File
@@ -180,6 +180,29 @@
padding: 0.25rem 0.375rem;
}
.admin-btn-primary {
background: var(--accent, var(--info));
color: var(--white);
border-color: transparent;
}
.admin-btn-primary:hover {
background: var(--accent-hover, var(--accent));
color: var(--white);
}
.admin-btn-danger {
background: var(--danger);
color: var(--white);
border-color: transparent;
}
.admin-btn-danger:hover {
background: var(--danger);
color: var(--white);
opacity: 0.85;
}
.admin-select {
font-size: 0.75rem;
padding: 0.25rem 0.375rem;
-12
View File
@@ -133,18 +133,6 @@
.ci-grid { grid-template-columns: 1fr; }
}
.admin-btn-primary {
background: var(--accent, var(--info));
color: var(--white);
border-color: transparent;
}
.admin-btn-danger {
background: var(--danger);
color: var(--white);
border-color: transparent;
}
.cm-form {
display: flex;
flex-direction: column;
+131
View File
@@ -0,0 +1,131 @@
/* retoor <retoor@molodetz.nl> */
.gw-intro {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: var(--space-lg);
max-width: 760px;
}
.gw-section {
margin-bottom: var(--space-2xl);
}
.gw-section-head {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.gw-section-head h3 {
font-size: 1.0625rem;
font-weight: 700;
color: var(--text-primary);
}
.gw-section-hint {
color: var(--text-muted);
font-size: 0.8125rem;
margin-bottom: var(--space-md);
max-width: 760px;
}
.gw-default {
padding: 0.75rem 1rem;
background: var(--bg-card-hover);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: var(--space-md);
}
.gw-default code,
.admin-table .gw-code {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
}
.gw-default a {
color: var(--accent);
}
.gw-muted {
color: var(--text-muted);
}
.gw-actions {
text-align: right;
white-space: nowrap;
}
.gw-form {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-top: var(--space-md);
max-width: 960px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--space-md);
}
.gw-form-title {
grid-column: 1 / -1;
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
}
.gw-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.gw-field.gw-wide {
grid-column: 1 / -1;
}
.gw-field label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.gw-field input,
.gw-field select {
width: 100%;
padding: 0.5rem 0.65rem;
background: var(--bg-input, var(--bg-card));
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-input, var(--radius));
font: inherit;
}
.gw-field input:focus,
.gw-field select:focus {
outline: none;
border-color: var(--accent);
}
.gw-form-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
}
@media (max-width: 640px) {
.gw-form {
grid-template-columns: 1fr;
}
.gw-actions {
text-align: left;
}
}
+267
View File
@@ -0,0 +1,267 @@
// 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 = [];
}
async start() {
this.bind();
await this.reload();
}
bind() {
this.providerForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveProvider();
});
this.modelForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveModel();
});
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
}
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 count = this.root.querySelector("#gw-count");
if (count) {
count.textContent = `${providerCount} providers, ${modelCount} routes`;
}
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
attr(value) {
return this.escape(value).split('"').join("&quot;");
}
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>, 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 || [];
if (!models.length) {
this.modelsBody.innerHTML = `<tr><td colspan="6" 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";
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 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;
}
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,
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 confirmDelete(message) {
if (window.app && window.app.dialog) {
return window.app.dialog.confirm({ message, danger: true, confirmLabel: "Delete" });
}
return window.confirm(message);
}
async remove(url) {
const response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
if (!response.ok && response.status !== 404) {
throw new Error(`Delete failed: ${response.status}`);
}
}
async deleteProvider(name) {
if (!(await this.confirmDelete(`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";
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.is_active.value = model.is_active ? "1" : "0";
form.source_model.scrollIntoView({ block: "center" });
}
async deleteModel(source) {
if (!(await this.confirmDelete(`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");
}
}
}
+3
View File
@@ -23,6 +23,9 @@
<a href="/admin/services" class="sidebar-link {% if admin_section == 'services' %}active{% endif %}">
<span class="sidebar-icon">&#x2699;&#xFE0F;</span> Services
</a>
<a href="/admin/gateway" class="sidebar-link {% if admin_section == 'gateway' %}active{% endif %}">
<span class="sidebar-icon">&#x1F500;</span> Gateway
</a>
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4E6;</span> Containers
</a>
+75
View File
@@ -0,0 +1,75 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<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"></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. 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"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Name</th><th>Base URL</th><th>Active</th><th class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-providers"></tbody>
</table>
</div>
<form class="gw-form" id="gw-provider-form" autocomplete="off">
<p class="gw-form-title">Add or update provider</p>
<div class="gw-field"><label>Name</label><input type="text" name="name" placeholder="openrouter" required></div>
<div class="gw-field"><label>Base URL (chat completions)</label><input type="url" name="base_url" placeholder="https://openrouter.ai/api/v1/chat/completions"></div>
<div class="gw-field"><label>API key</label><input type="password" name="api_key" placeholder="sk-..." autocomplete="new-password"></div>
<div class="gw-field"><label>Active</label><select 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 (USD per 1M tokens). Chat uses cache-hit, cache-miss and output prices; embeddings use the input price; a vision model adds input and output pricing for the image description merge.</p>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Source</th><th>Provider</th><th>Target</th><th>Kind</th><th>Vision</th><th class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-models"></tbody>
</table>
</div>
<form class="gw-form" id="gw-model-form" autocomplete="off">
<p class="gw-form-title">Add or update model route</p>
<div class="gw-field"><label>Source model (requested)</label><input type="text" name="source_model" placeholder="gpt-4o" required></div>
<div class="gw-field"><label>Provider</label><select name="provider" data-provider-select></select></div>
<div class="gw-field"><label>Target model (upstream)</label><input type="text" name="target_model" placeholder="openai/gpt-4o" required></div>
<div class="gw-field"><label>Kind</label><select name="kind"><option value="chat">chat</option><option value="embed">embed</option></select></div>
<div class="gw-field"><label>Vision provider</label><select name="vision_provider" data-provider-select></select></div>
<div class="gw-field"><label>Vision model</label><input type="text" name="vision_model" placeholder="(optional) google/gemma-3-12b-it"></div>
<div class="gw-field"><label>Context window</label><input type="number" name="context_window" min="0" value="0"></div>
<div class="gw-field"><label>Active</label><select name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label>Price cache-hit / 1M ($)</label><input type="number" name="price_cache_hit_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label>Price cache-miss / 1M ($)</label><input type="number" name="price_cache_miss_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label>Price output / 1M ($)</label><input type="number" name="price_output_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label>Price input / 1M ($) (embed/vision)</label><input type="number" name="price_input_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save model route</button></div>
</form>
</section>
</div>
{% endblock %}
{% block extra_js %}
<script type="module">
import { GatewayAdmin } from "{{ static_url('/static/js/GatewayAdmin.js') }}";
new GatewayAdmin(document.getElementById("gateway-admin")).start();
</script>
{% endblock %}
@@ -13,10 +13,11 @@ It forwards chat-completions and other `/v1/*` calls to a configured upstream (D
```
POST /openai/v1/chat/completions
POST /openai/v1/embeddings
GET|POST|PUT|DELETE|PATCH /openai/v1/{path} passthrough
```
Chat requests are augmented (vision), forwarded, retried, priced, and ledgered. Other paths pass through to the upstream.
Chat requests are augmented (vision), forwarded, retried, priced, and ledgered. Embeddings forward to the configured embeddings upstream. Other paths pass through to the upstream.
## Authentication and attribution
@@ -44,9 +45,20 @@ Chat requests are augmented (vision), forwarded, retried, priced, and ledgered.
**Tracking:** `gateway_usage_retention_hours` (default 720), `gateway_model_context_map` (JSON mapping model name to context window for utilization tracking).
## Model routing and providers
The single upstream above is the implicit `default` provider. On top of it, an administrator can register additional named **providers** and map any number of requested model names onto them, so one gateway can front many models across many backends. Routing is managed on the **Gateway** page (`/admin/gateway`) and stored in two tables (`gateway_providers`, `gateway_models`); it is admin configuration, not user content.
- A **provider** is a named upstream: `name`, a chat-completions `base_url`, and an `api_key`. The embeddings URL is derived from the base URL by swapping `/chat/completions` for `/embeddings`.
- A **model route** maps a `source_model` (what a client requests) to a `provider` (blank uses the default upstream) and a `target_model` (sent upstream), with a `kind` of `chat` or `embed`, an optional `context_window`, an optional vision provider and model, and its own per-model **economy** (USD per 1M tokens): `price_cache_hit_per_m`, `price_cache_miss_per_m`, `price_output_per_m`, and `price_input_per_m` (the input price covers embeddings and the vision description).
When a request's model matches an active route of the right kind, the gateway forwards to that route's provider and target model and meters the call against that route's economy. A route with a vision model turns on the **text-and-vision merge** for that route, so a text-only model can answer about images. When no route matches, the request falls through to the default upstream unchanged, so `molodetz`, `molodetz~embed`, and every existing client keep behaving exactly as before. Resolution is a per-request overlay onto the service config, so all of the behavior below (authentication, system-message composition, vision augmentation, retries, the circuit breaker, the ledger, and the `X-Gateway-*` headers) applies identically to routed requests.
Routes and providers are also managed conversationally by an administrator through Devii (`gateway_providers`, `gateway_models` to read; `gateway_provider_set`, `gateway_provider_delete`, `gateway_model_set`, `gateway_model_delete` to manage, with deletes confirmation-gated).
## Cost computation
If the upstream response carries a numeric `cost` field, that value is used verbatim and split proportionally into input and output (the row is flagged `native_cost`). Otherwise cost is computed from the per-1M pricing: for chat, `cache_hit_tokens` and `cache_miss_tokens` are priced separately on input plus `completion_tokens` on output; for vision, input and output use the vision rates. Prompt-cache hit rate directly drives cost: at the default rates cache hits are roughly fifty times cheaper than misses.
If the upstream response carries a numeric `cost` field, that value is used verbatim and split proportionally into input and output (the row is flagged `native_cost`). Otherwise cost is computed from the per-1M pricing: for chat, `cache_hit_tokens` and `cache_miss_tokens` are priced separately on input plus `completion_tokens` on output; for vision, input and output use the vision rates. When the requested model matches a model route, that route's per-model prices are used instead of the service-level pricing fields, so each model carries its own economy. Prompt-cache hit rate directly drives cost: at the default rates cache hits are roughly fifty times cheaper than misses.
## Response headers