forked from retoor/devplacepy
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(""");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
<a href="/admin/services" class="sidebar-link {% if admin_section == 'services' %}active{% endif %}">
|
||||
<span class="sidebar-icon">⚙️</span> Services
|
||||
</a>
|
||||
<a href="/admin/gateway" class="sidebar-link {% if admin_section == 'gateway' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🔀</span> Gateway
|
||||
</a>
|
||||
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📦</span> Containers
|
||||
</a>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user