현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -4,9 +4,10 @@ from __future__ import annotations
import time
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from ..auth_sessions import (
@ -23,11 +24,13 @@ from ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import voice_service
from ..services import rag
from ..store import store
router = APIRouter(prefix="/admin", tags=["admin"])
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
HealthStatus = Literal["ok", "degraded", "down"]
UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"]
class AdminServiceHealth(BaseModel):
@ -46,6 +49,36 @@ class AdminHealthResponse(BaseModel):
services: list[AdminServiceHealth]
class AdminUsageBreakdown(BaseModel):
provider: str
model: str
turns: int
tokens_in: int
tokens_out: int
cost_usd: float
class AdminUsageBudget(BaseModel):
limit_usd: float
used_ratio: float
remaining_usd: float | None
status: UsageBudgetStatus
class AdminUsageResponse(BaseModel):
source: Literal["database", "server_session_registry"]
durable: bool
window_days: int
generated_at: float
total_turns: int
metered_turns: int
tokens_in: int
tokens_out: int
cost_usd: float
budget: AdminUsageBudget
by_provider: list[AdminUsageBreakdown]
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_url: str
@ -120,6 +153,47 @@ def _workload_load(count: int, expected_capacity: int) -> float:
return _clamp01(count / expected_capacity)
def _decimal_to_float(value: object) -> float:
if value is None:
return 0.0
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _safe_usage_int(value: object) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _usage_budget(cost_usd: float) -> AdminUsageBudget:
limit = max(0.0, float(settings.admin_usage_budget_usd or 0.0))
if limit <= 0:
return AdminUsageBudget(
limit_usd=0.0,
used_ratio=0.0,
remaining_usd=None,
status="disabled",
)
used_ratio = max(0.0, cost_usd / limit)
status_value: UsageBudgetStatus = "ok"
if used_ratio >= 1.0:
status_value = "exceeded"
elif used_ratio >= 0.8:
status_value = "warn"
return AdminUsageBudget(
limit_usd=round(limit, 6),
used_ratio=round(used_ratio, 4),
remaining_usd=round(max(0.0, limit - cost_usd), 6),
status=status_value,
)
async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
metrics = RuntimeHealthMetrics()
@ -175,6 +249,157 @@ async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
return metrics
async def _usage_from_database(window_days: int) -> AdminUsageResponse:
async with acquire(role="admin") as conn:
total_row = await conn.fetchrow(
"""
SELECT
COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns,
COUNT(*) FILTER (
WHERE speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
) AS metered_turns,
COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
""",
window_days,
)
rows = await conn.fetch(
"""
SELECT
COALESCE(llm_provider, 'unknown') AS provider,
COALESCE(model, 'unknown') AS model,
COUNT(*) AS turns,
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
AND speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
GROUP BY 1, 2
ORDER BY cost_usd DESC, tokens_in + tokens_out DESC, turns DESC
LIMIT 12
""",
window_days,
)
total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6)
return AdminUsageResponse(
source="database",
durable=True,
window_days=window_days,
generated_at=time.time(),
total_turns=_safe_usage_int(total_row["total_turns"] if total_row else 0),
metered_turns=_safe_usage_int(total_row["metered_turns"] if total_row else 0),
tokens_in=_safe_usage_int(total_row["tokens_in"] if total_row else 0),
tokens_out=_safe_usage_int(total_row["tokens_out"] if total_row else 0),
cost_usd=total_cost,
budget=_usage_budget(total_cost),
by_provider=[
AdminUsageBreakdown(
provider=str(row["provider"] or "unknown"),
model=str(row["model"] or "unknown"),
turns=_safe_usage_int(row["turns"]),
tokens_in=_safe_usage_int(row["tokens_in"]),
tokens_out=_safe_usage_int(row["tokens_out"]),
cost_usd=round(_decimal_to_float(row["cost_usd"]), 6),
)
for row in rows
],
)
def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
window_start = time.time() - (window_days * 86400)
total_turns = 0
metered_turns = 0
tokens_in = 0
tokens_out = 0
cost_usd = 0.0
buckets: dict[tuple[str, str], dict[str, int | float]] = {}
for sess in store.list():
for turn in getattr(sess, "turns", []) or []:
if getattr(turn, "speaker", "") != "client":
continue
if float(getattr(turn, "created_at", 0.0) or 0.0) < window_start:
continue
total_turns += 1
provider = str(getattr(turn, "llm_provider", None) or "unknown")
model = str(getattr(turn, "model", None) or "unknown")
turn_tokens_in = _safe_usage_int(getattr(turn, "tokens_in", 0))
turn_tokens_out = _safe_usage_int(getattr(turn, "tokens_out", 0))
turn_cost = _decimal_to_float(getattr(turn, "cost_usd", 0.0))
is_metered = (
provider != "unknown"
or model != "unknown"
or turn_tokens_in > 0
or turn_tokens_out > 0
or turn_cost > 0
)
if not is_metered:
continue
metered_turns += 1
tokens_in += turn_tokens_in
tokens_out += turn_tokens_out
cost_usd += turn_cost
key = (provider, model)
bucket = buckets.setdefault(
key,
{"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0},
)
bucket["turns"] = int(bucket["turns"]) + 1
bucket["tokens_in"] = int(bucket["tokens_in"]) + turn_tokens_in
bucket["tokens_out"] = int(bucket["tokens_out"]) + turn_tokens_out
bucket["cost_usd"] = float(bucket["cost_usd"]) + turn_cost
by_provider = [
AdminUsageBreakdown(
provider=provider,
model=model,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
)
for (provider, model), values in sorted(
buckets.items(),
key=lambda item: (
-float(item[1]["cost_usd"]),
-(int(item[1]["tokens_in"]) + int(item[1]["tokens_out"])),
-int(item[1]["turns"]),
),
)[:12]
]
total_cost = round(cost_usd, 6)
return AdminUsageResponse(
source="server_session_registry",
durable=False,
window_days=window_days,
generated_at=time.time(),
total_turns=total_turns,
metered_turns=metered_turns,
tokens_in=tokens_in,
tokens_out=tokens_out,
cost_usd=total_cost,
budget=_usage_budget(total_cost),
by_provider=by_provider,
)
class AdminUserCreate(BaseModel):
email: str = Field(..., min_length=3, max_length=254)
display_name: str = Field(..., min_length=1, max_length=80)
@ -407,6 +632,19 @@ async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
)
@router.get("/usage", response_model=AdminUsageResponse)
async def admin_usage(
principal: AdminPrincipal,
window_days: Annotated[int, Query(ge=1, le=90)] = 7,
) -> AdminUsageResponse:
"""Return AI token/cost usage from persisted turns or dev fallback state."""
try:
return await _usage_from_database(window_days)
except Exception:
require_runtime_fallback_allowed("admin usage")
return _usage_from_runtime_store(window_days)
@router.get("/engine-config", response_model=AdminEngineConfigResponse)
async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse:
"""Return the current admin-managed engine settings."""