현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue