Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ For OpenAI-compatible providers, a configured provider `api_key` takes precedenc
- Use `-C/--config` or `ROUTES_CONFIG` for deterministic routing config selection.
- Use `SMOLROUTER_SECRETS` for deterministic secrets file selection; otherwise SmolRouter searches `./secrets.yaml`, the user config dir, then the site config dir.
- Use `SMOLROUTER_FACADE_KEYS` for dedicated facade-key storage (`./facade_keys.yaml`, user config dir, then site config dir).
- Provision facade keys with `python -m smolrouter.manage_facade_keys create --project <id> --routes-config <routes.yaml>` (generates `srk-...` secrets; default append behavior avoids clobbering old secrets).
- Manage projects and facade API keys from **Projects** in the Web UI. Project metadata stays in the routes YAML/JSON and secrets stay in `facade_keys.yaml`; new keys are shown once for copying, while existing keys are represented only by SHA-256 fingerprints. Saving project metadata normalizes the routes file, so YAML comments and hand formatting may not be retained.
- For non-interactive automation, `python -m smolrouter.manage_facade_keys create --project <id> --routes-config <routes.yaml>` remains available and uses the same locked file-management path as the UI.
- Use `BLOB_STORAGE_PATH` explicitly in production if you want a location other than the safe default under `~/.smolrouter/blob_storage`.
- For dev checkouts, `BLOB_STORAGE_PATH=./blob_storage` keeps blobs next to the repo as before.
- `docker-compose.yml` mounts `./logs:/app/logs`, so rotated ERROR logs persist across container restarts.
Expand All @@ -263,7 +264,7 @@ Configure `request_rate_limits` in the routes config. The anonymous and project

Requests with invalid or mismatched local keys receive `401` before consuming a bucket. An admitted request consumes capacity before its JSON body is parsed or sent upstream. A rejected request receives `429` with OpenAI-style error code `anonymous_rate_limit_exceeded` or `project_rate_limit_exceeded`, a whole-second `Retry-After`, and policy/bucket-class headers that contain no project key or identity. Redis failures fail closed with `503` and code `rate_limiter_unavailable`; production startup also verifies the limiter and refuses FakeRedis when limiting is enabled.

The Projects UI and API expose each configured project's effective two-window policy and whether it comes from the project default or a whole-project override. They do not expose live Redis counters. Request graphs, filters, and drilldowns only cover logs still present in the configured request-log retention window, so they are not authoritative rate-limit accounting.
The Projects UI can create and delete projects and create or revoke their facade API keys. It also exposes each configured project's effective two-window policy and whether it comes from the project default or a whole-project override. Stored secrets are never returned after creation; the UI and read APIs expose only full SHA-256 fingerprints and counts. They do not expose live Redis counters. Request graphs, filters, and drilldowns only cover logs still present in the configured request-log retention window, so they are not authoritative rate-limit accounting.

## Testing

Expand Down
2 changes: 1 addition & 1 deletion STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Google GenAI provider has extra features like key status tracking and exhaustion detection. Need to ensure consistent IA across all providers (OpenAI, Anthropic, Ollama). Consider: Should other providers have similar detailed status tracking? Location: Provider interfaces and dashboard consistency.

- 🔴 Facade-key identity and attribution (Phase A complete, Phase B first milestone complete)
Treat facade API keys as first-class SmolRouter identity for request attribution, soft quota accounting, routing/QoS defaults, and future batch approval policy. Phase A groundwork is in place: facade-key config/secret registry, validation, and container plumbing. Phase B now accepts local facade keys on the OpenAI write path (`/v1/chat/completions`, `/v1/completions`, `/v1/responses`) via `Authorization: Bearer srk-...`, keeps `X-SmolRouter-Key` as a transitional alias, exempts those write routes from JWT-in-`Authorization` collision, threads identity through `ClientContext`, persists a generic request subject (`identity_kind`, `identity_subject_id`, `identity_display_name`) on request creation, surfaces project identity in dashboard/client/request-detail views, and adds read-only `/projects` inventory for configured facade-key identities plus `/projects/{id}` drilldown backed by a Redis identity-recency index. Facade-key provisioning now has an operator CLI (`python -m smolrouter.manage_facade_keys create`) and dedicated secret boundary (`facade_keys.yaml` / `SMOLROUTER_FACADE_KEYS`) as the single runtime source of truth for facade-key secrets. Future deliberate gaps: no quota ledger yet, no hard enforcement, no Ollama compatibility-path parity, no `/v1/models` or `/api/tags` identity resolution yet, and no broader dashboard/browser auth redesign or provider-BYOK transport cleanup yet. Historical traffic-only identities are reachable via drilldown when known, but are not listed as inventory. Full design: `docs/PROPOSAL_facade_key_identity_accounting.md`.
Treat facade API keys as first-class SmolRouter identity for request attribution, soft quota accounting, routing/QoS defaults, and future batch approval policy. Phase A groundwork is in place: facade-key config/secret registry, validation, and container plumbing. Phase B now accepts local facade keys on the OpenAI write path (`/v1/chat/completions`, `/v1/completions`, `/v1/responses`) via `Authorization: Bearer srk-...`, keeps `X-SmolRouter-Key` as a transitional alias, exempts those write routes from JWT-in-`Authorization` collision, threads identity through `ClientContext`, persists a generic request subject (`identity_kind`, `identity_subject_id`, `identity_display_name`) on request creation, and surfaces project identity in dashboard/client/request-detail views. The `/projects` UI now creates and deletes file-backed projects and creates/revokes facade API keys with one-time secret display; the CLI remains an automation fallback and shares the same locked mutation path. Project metadata remains in routes YAML/JSON, dedicated secrets remain in `facade_keys.yaml` / `SMOLROUTER_FACADE_KEYS`, and read views expose only key fingerprints. Future deliberate gaps: no quota ledger yet, no hard enforcement, no Ollama compatibility-path parity, no `/v1/models` or `/api/tags` identity resolution yet, and no broader dashboard/browser auth redesign or provider-BYOK transport cleanup yet. Historical traffic-only identities are reachable via drilldown when known, but are not listed as inventory. Full design: `docs/PROPOSAL_facade_key_identity_accounting.md`.

- 🟡 Facade-key request/token accounting and soft quotas
Build on facade-key identity first, then add approximate request/token accounting and soft quota state for local project/use-case keys. Keep semantics honest: soft/best-effort, streaming-aware, and fail-open on accounting gaps. This should precede broad provider-key accounting convergence because the main product question is "which local project/use case consumed the budget?" not just "which downstream secret was used?". Full design: `docs/PROPOSAL_facade_key_identity_accounting.md`.
Expand Down
210 changes: 200 additions & 10 deletions smolrouter/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, AsyncIterator, Dict, Optional, Tuple
from typing import Annotated, Any, AsyncIterator, Dict, Optional, Tuple
from datetime import datetime, timezone
from urllib.parse import quote, urlparse

from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, HTTPException
from fastapi import Depends, FastAPI, Request, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict
import httpx
from contextlib import asynccontextmanager

Expand Down Expand Up @@ -48,6 +49,7 @@
from smolrouter.container import initialize_container
from smolrouter.config_paths import normalize_provider_file_references, resolve_routes_config_path
from smolrouter.facade_keys import RequestIdentity
from smolrouter.project_key_manager import ProjectKeyManagementError, facade_key_id
from smolrouter.redis_config import is_fake_redis
from smolrouter.request_rate_limits import (
RateLimitDecision,
Expand Down Expand Up @@ -1407,7 +1409,7 @@ async def _handle_request_without_local_facade_key(
)


def _resolve_request_identity(
async def _resolve_request_identity(
active_container: Any,
request: Request,
presented_key: Optional[str] = None,
Expand All @@ -1416,7 +1418,7 @@ def _resolve_request_identity(
if not presented_key:
return None

registry = active_container.get_facade_key_registry()
registry = await asyncio.to_thread(active_container.get_facade_key_registry)
resolved = registry.resolve_secret(presented_key)
if resolved is None or resolved.identity is None:
raise HTTPException(status_code=401, detail=_invalid_local_facade_key_detail())
Expand Down Expand Up @@ -1450,6 +1452,7 @@ def _build_project_inventory_entry(
secret_count: int = 0,
effective_rate_limit: Any = None,
rate_limits_enabled: Optional[bool] = None,
keys: Optional[list[dict[str, str]]] = None,
) -> dict[str, Any]:
if config is None:
display_name = None
Expand Down Expand Up @@ -1481,6 +1484,7 @@ def _build_project_inventory_entry(
"enabled": enabled,
"display_name": display_name,
"secret_count": int(secret_count),
"keys": list(keys or []),
"default_class": default_class,
"tags": list(tags),
"quota": quota_payload,
Expand All @@ -1498,13 +1502,14 @@ async def _get_facade_key_registry_data() -> dict[str, Any]:
if active_container is None:
return {}

registry = active_container.get_facade_key_registry()
registry = await asyncio.to_thread(active_container.get_facade_key_registry)
rate_limit_config = _request_rate_limit_config(active_container)

return {
key_id: {
"config": config,
"secret_count": len(registry.get_secrets(key_id)),
"keys": [{"key_id": facade_key_id(secret)} for secret in registry.get_secrets(key_id)],
"effective_rate_limit": config.rate_limit or rate_limit_config.project_default,
"rate_limits_enabled": rate_limit_config.enabled,
}
Expand All @@ -1524,6 +1529,7 @@ async def _load_facade_key_inventory() -> dict[str, dict[str, Any]]:
secret_count=entry["secret_count"],
effective_rate_limit=entry["effective_rate_limit"],
rate_limits_enabled=entry["rate_limits_enabled"],
keys=entry["keys"],
)
for project_id, entry in sorted(registry_data.items(), key=lambda item: item[0])
}
Expand Down Expand Up @@ -1711,20 +1717,20 @@ def _build_request_tracking_headers(log_entry: Any) -> Dict[str, str]:
return headers


def _resolve_presented_facade_identity(
async def _resolve_presented_facade_identity(
active_container: Any,
request: Request,
presented_facade_key: Optional[str],
presented_authorization_facade_key: Optional[str],
resolved_facade_key: Optional[str],
) -> Optional[RequestIdentity]:
if presented_facade_key and presented_authorization_facade_key:
header_identity = _resolve_request_identity(
header_identity = await _resolve_request_identity(
active_container,
request,
presented_key=presented_facade_key,
)
authorization_identity = _resolve_request_identity(
authorization_identity = await _resolve_request_identity(
active_container,
request,
presented_key=presented_authorization_facade_key,
Expand All @@ -1733,7 +1739,7 @@ def _resolve_presented_facade_identity(
raise HTTPException(status_code=401, detail="Mismatched local facade key values")
return authorization_identity

return _resolve_request_identity(active_container, request, presented_key=resolved_facade_key)
return await _resolve_request_identity(active_container, request, presented_key=resolved_facade_key)


async def _prepare_proxy_request_context(
Expand Down Expand Up @@ -1801,7 +1807,7 @@ async def _prepare_proxy_request_context(
)

try:
identity = _resolve_presented_facade_identity(
identity = await _resolve_presented_facade_identity(
active_container,
request,
presented_facade_key,
Expand Down Expand Up @@ -2522,6 +2528,56 @@ async def proxy_ollama_request(path: str, request: Request) -> JSONResponse | St
404: {"description": PROJECT_NOT_FOUND_DETAIL},
}

PROJECT_MANAGEMENT_BASE_RESPONSES = {
401: {"description": "Web UI authentication required"},
403: {"description": "Web UI access denied or request origin rejected"},
415: {"description": "Content-Type must be application/json"},
422: {"description": "Invalid mutation request"},
500: {"description": "Project/key management unavailable or durable mutation failed"},
}
CREATE_PROJECT_RESPONSES = {
**PROJECT_MANAGEMENT_BASE_RESPONSES,
409: {"description": "Project already exists or configuration changed concurrently"},
}
DELETE_PROJECT_RESPONSES = {
**PROJECT_MANAGEMENT_BASE_RESPONSES,
404: {"description": PROJECT_NOT_FOUND_DETAIL},
409: {"description": "Historical-only project or configuration conflict"},
}
CREATE_KEY_RESPONSES = {
**PROJECT_MANAGEMENT_BASE_RESPONSES,
404: {"description": PROJECT_NOT_FOUND_DETAIL},
409: {"description": "Configuration conflict"},
}
DELETE_KEY_RESPONSES = {
**PROJECT_MANAGEMENT_BASE_RESPONSES,
404: {"description": "Project or key not found"},
409: {"description": "Ambiguous key fingerprint or configuration conflict"},
}


class _StrictMutationBody(BaseModel):
model_config = ConfigDict(extra="forbid")


class ProjectCreateBody(_StrictMutationBody):
project_id: str
display_name: Optional[str] = None
tags: Optional[list[str]] = None


class ProjectDeleteBody(_StrictMutationBody):
project_id: str


class ProjectKeyCreateBody(_StrictMutationBody):
project_id: str


class ProjectKeyDeleteBody(_StrictMutationBody):
project_id: str
key_id: str


@app.post("/v1/chat/completions", responses=OPENAI_PROXY_ROUTE_RESPONSES)
async def chat_completions(request: Request):
Expand Down Expand Up @@ -2869,6 +2925,140 @@ async def api_projects(request: Request):
raise HTTPException(status_code=500, detail="Failed to load project inventory")


def _single_raw_header(request: Request, name: bytes) -> str:
values = [value.decode("latin-1") for key, value in request.scope.get("headers", []) if key.lower() == name]
if len(values) != 1 or not values[0].strip():
raise HTTPException(status_code=403, detail=f"Exactly one {name.decode()} header is required")
return values[0].strip()


def _canonical_origin(value: str) -> tuple[str, str, int]:
try:
parsed = urlparse(value)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.params
or parsed.query
or parsed.fragment
):
raise ValueError
return parsed.scheme, parsed.hostname.lower(), parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Malformed Origin header") from exc


def _check_project_mutation_request(request: Request) -> None:
get_webui_security().check_webui_access(request)
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise HTTPException(status_code=415, detail="Content-Type must be application/json")
origin = _single_raw_header(request, b"origin")
_single_raw_header(request, b"host")
if origin.lower() == "null" or _canonical_origin(origin) != _canonical_origin(str(request.base_url).rstrip("/")):
raise HTTPException(status_code=403, detail="Origin must exactly match the request origin")


async def _project_key_manager():
active_container = await _get_active_container(False)
if active_container is None:
raise HTTPException(status_code=500, detail="Project/key management is unavailable")
try:
return active_container.get_project_key_manager()
except Exception as exc:
raise HTTPException(status_code=500, detail="Project/key management is unavailable") from exc


def _management_error(exc: ProjectKeyManagementError) -> HTTPException:
return HTTPException(status_code=exc.status_code, detail=str(exc))


def _project_mutation_guard(request: Request) -> None:
_check_project_mutation_request(request)


ProjectMutationGuard = Annotated[None, Depends(_project_mutation_guard)]


def _mutation_response(payload: dict[str, Any], status_code: int = 200) -> JSONResponse:
return JSONResponse(
payload,
status_code=status_code,
headers={"Cache-Control": "no-store, no-cache", "Pragma": "no-cache"},
)


@app.post(
"/api/project-management/projects",
status_code=201,
responses=CREATE_PROJECT_RESPONSES,
)
async def create_managed_project(
body: ProjectCreateBody,
_guard: ProjectMutationGuard,
):
manager = await _project_key_manager()
try:
project = await asyncio.to_thread(manager.create_project, body.project_id, body.display_name, body.tags)
return _mutation_response({"project": project}, 201)
except ProjectKeyManagementError as exc:
raise _management_error(exc)


@app.delete("/api/project-management/projects", responses=DELETE_PROJECT_RESPONSES)
async def delete_managed_project(
body: ProjectDeleteBody,
_guard: ProjectMutationGuard,
):
manager = await _project_key_manager()
try:
registry = await asyncio.to_thread(manager.get_registry)
if not registry.has_key(body.project_id):
historical = await RequestLog.get_by_identity("facade_key", body.project_id, limit=1)
if historical:
raise HTTPException(status_code=409, detail="Historical-only projects cannot be deleted")
await asyncio.to_thread(manager.delete_project, body.project_id)
return _mutation_response({"deleted": True, "project_id": body.project_id})
except ProjectKeyManagementError as exc:
raise _management_error(exc)


@app.post(
"/api/project-management/keys",
status_code=201,
responses=CREATE_KEY_RESPONSES,
)
async def create_managed_key(
body: ProjectKeyCreateBody,
_guard: ProjectMutationGuard,
):
manager = await _project_key_manager()
try:
secret, key_id = await asyncio.to_thread(manager.create_key, body.project_id)
return _mutation_response(
{"project_id": body.project_id, "key_id": key_id, "secret": secret},
201,
)
except ProjectKeyManagementError as exc:
raise _management_error(exc)


@app.delete("/api/project-management/keys", responses=DELETE_KEY_RESPONSES)
async def delete_managed_key(
body: ProjectKeyDeleteBody,
_guard: ProjectMutationGuard,
):
manager = await _project_key_manager()
try:
await asyncio.to_thread(manager.revoke_key, body.project_id, body.key_id)
return _mutation_response({"deleted": True, "project_id": body.project_id, "key_id": body.key_id})
except ProjectKeyManagementError as exc:
raise _management_error(exc)


@app.get("/api/projects/{project_id:path}", responses=PROJECT_DETAIL_API_RESPONSES)
async def api_project_detail(request: Request, project_id: str, limit: int = 100):
get_webui_security().check_webui_access(request)
Expand Down
Loading