777 lines
26 KiB
Python
777 lines
26 KiB
Python
"""Admin operations routes."""
|
|
|
|
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, Query, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..auth_sessions import (
|
|
active_session_count,
|
|
deactivate_managed_user,
|
|
list_managed_users,
|
|
update_managed_user,
|
|
upsert_managed_user,
|
|
)
|
|
from ..config import settings
|
|
from ..db import acquire, get_pool, healthcheck
|
|
from ..deps import Principal, Role, require_role
|
|
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):
|
|
key: str
|
|
name: str
|
|
status: HealthStatus
|
|
detail: str
|
|
metric: str
|
|
load: float
|
|
|
|
|
|
class AdminHealthResponse(BaseModel):
|
|
status: HealthStatus
|
|
environment: str
|
|
engine_mode: str
|
|
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
|
|
model: str
|
|
updated_by: str | None = None
|
|
updated_at: float | None = None
|
|
durable: bool = False
|
|
source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default"
|
|
|
|
|
|
class AdminEngineConfigPatch(BaseModel):
|
|
engine_mode: str | None = None
|
|
engine_url: str | None = None
|
|
model: str | None = None
|
|
|
|
|
|
RoleName = Literal["learner", "teacher", "admin"]
|
|
|
|
|
|
class AdminUserResponse(BaseModel):
|
|
user_id: str
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
cohort_ids: list[str]
|
|
affiliation: str
|
|
active_sessions: int
|
|
created_at: float
|
|
last_seen_at: float
|
|
source: Literal["database", "server_session_registry"]
|
|
|
|
|
|
class AdminUsersResponse(BaseModel):
|
|
source: Literal["database", "server_session_registry"]
|
|
durable: bool
|
|
users: list[AdminUserResponse]
|
|
|
|
|
|
class AdminUserPatch(BaseModel):
|
|
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
|
role: RoleName | None = None
|
|
affiliation: str | None = Field(default=None, max_length=120)
|
|
cohort_ids: list[str] | None = None
|
|
|
|
|
|
class RuntimeHealthMetrics(BaseModel):
|
|
engine_latency_ms: float | None = None
|
|
db_pool_size: int = 0
|
|
db_pool_idle: int = 0
|
|
db_pool_max: int = 0
|
|
active_users: int = 0
|
|
active_auth_sessions: int = 0
|
|
active_sessions: int = 0
|
|
ended_sessions: int = 0
|
|
pending_reviews: int = 0
|
|
|
|
|
|
def _clamp01(value: float) -> float:
|
|
return round(max(0.0, min(1.0, value)), 3)
|
|
|
|
|
|
def _pool_load(metrics: RuntimeHealthMetrics) -> float:
|
|
if metrics.db_pool_max <= 0:
|
|
return 0.0
|
|
busy = max(0, metrics.db_pool_size - metrics.db_pool_idle)
|
|
return _clamp01(busy / metrics.db_pool_max)
|
|
|
|
|
|
def _workload_load(count: int, expected_capacity: int) -> float:
|
|
if expected_capacity <= 0:
|
|
return 0.0
|
|
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()
|
|
|
|
try:
|
|
pool = get_pool()
|
|
metrics.db_pool_size = int(pool.get_size())
|
|
metrics.db_pool_idle = int(pool.get_idle_size())
|
|
metrics.db_pool_max = int(pool.get_max_size())
|
|
except Exception:
|
|
pass
|
|
|
|
if not db_ok:
|
|
return metrics
|
|
|
|
try:
|
|
async with acquire(role="admin") as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM app.auth_session
|
|
WHERE revoked_at IS NULL AND expires_at > now()
|
|
) AS active_auth_sessions,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM app.sessions
|
|
WHERE ended_at IS NULL
|
|
) AS active_sessions,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM app.sessions
|
|
WHERE ended_at IS NOT NULL
|
|
) AS ended_sessions,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM app.sessions s
|
|
LEFT JOIN app.session_summary ss ON ss.session_id = s.id
|
|
WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL
|
|
) AS pending_reviews
|
|
"""
|
|
)
|
|
if row is not None:
|
|
metrics.active_users = int(row["active_users"] or 0)
|
|
metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0)
|
|
metrics.active_sessions = int(row["active_sessions"] or 0)
|
|
metrics.ended_sessions = int(row["ended_sessions"] or 0)
|
|
metrics.pending_reviews = int(row["pending_reviews"] or 0)
|
|
except Exception:
|
|
return metrics
|
|
|
|
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)
|
|
role: RoleName = "learner"
|
|
affiliation: str | None = Field(default=None, max_length=120)
|
|
cohort_ids: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class AdminUserDeleteResponse(BaseModel):
|
|
ok: bool
|
|
user_id: str
|
|
|
|
|
|
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
|
|
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
|
|
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
|
|
|
|
|
|
def _normalize_email(value: str) -> str:
|
|
email = value.strip().lower()
|
|
if "@" not in email:
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
|
|
local, domain = email.rsplit("@", 1)
|
|
if not local or not domain:
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
|
|
allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()}
|
|
if domain not in allowed:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
|
|
return email
|
|
|
|
|
|
def _default_engine_config() -> AdminEngineConfigResponse:
|
|
return AdminEngineConfigResponse(
|
|
engine_mode=settings.engine_mode,
|
|
engine_url=settings.engine_url,
|
|
model="gateway-default",
|
|
durable=False,
|
|
source="runtime_default",
|
|
)
|
|
|
|
|
|
def _normalize_engine_mode(value: str) -> str:
|
|
mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip())
|
|
if mode not in ENGINE_MODES:
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"unsupported engine mode {value}",
|
|
)
|
|
return mode
|
|
|
|
|
|
def _normalize_engine_url(value: str) -> str:
|
|
url = value.strip().rstrip("/")
|
|
if not (url.startswith("http://") or url.startswith("https://")):
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="engine_url must start with http:// or https://",
|
|
)
|
|
return url
|
|
|
|
|
|
def _updated_at_ts(value: datetime | None) -> float | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.timestamp()
|
|
|
|
|
|
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
|
return AdminEngineConfigResponse(
|
|
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
|
engine_url=_normalize_engine_url(row["engine_url"]),
|
|
model=row["model"],
|
|
updated_by=row["updated_by"],
|
|
updated_at=_updated_at_ts(row["updated_at"]),
|
|
durable=True,
|
|
source="database",
|
|
)
|
|
|
|
|
|
async def _current_engine_config() -> AdminEngineConfigResponse:
|
|
if _ENGINE_CONFIG is not None:
|
|
return _ENGINE_CONFIG
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT engine_mode, engine_url, model, updated_by, updated_at
|
|
FROM app.admin_engine_config
|
|
WHERE id = TRUE
|
|
"""
|
|
)
|
|
if row is not None:
|
|
return _engine_config_from_row(row)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("admin engine config")
|
|
require_runtime_fallback_allowed("admin engine config")
|
|
return _default_engine_config()
|
|
|
|
|
|
async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
|
|
"""Load admin engine settings and apply them to the live engine client."""
|
|
config = await _current_engine_config()
|
|
await engine_client.configure(
|
|
base_url=config.engine_url,
|
|
engine_mode=config.engine_mode,
|
|
default_model=config.model,
|
|
)
|
|
return config
|
|
|
|
|
|
def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus:
|
|
if any(s.status == "down" for s in services):
|
|
return "down"
|
|
if any(s.status == "degraded" for s in services):
|
|
return "degraded"
|
|
return "ok"
|
|
|
|
|
|
def _engine_unavailable_detail(detail: str) -> str:
|
|
if detail.lstrip().startswith("{") and '"ok":false' in detail:
|
|
return "Engine readiness failed"
|
|
return detail
|
|
|
|
|
|
async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
|
|
return AdminUserResponse(
|
|
user_id=user.user_id,
|
|
email=user.email,
|
|
display_name=user.display_name,
|
|
role=user.role,
|
|
cohort_ids=user.cohort_ids,
|
|
affiliation=user.affiliation,
|
|
active_sessions=await active_session_count(user.user_id),
|
|
created_at=user.created_at,
|
|
last_seen_at=user.last_seen_at,
|
|
source="database" if durable else "server_session_registry",
|
|
)
|
|
|
|
|
|
@router.get("/health", response_model=AdminHealthResponse)
|
|
async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
|
|
"""Return operational health from live backend checks."""
|
|
current_engine = await _current_engine_config()
|
|
db_ok = await healthcheck()
|
|
engine_started = time.perf_counter()
|
|
engine = await engine_client.health_detail()
|
|
engine_ok = bool(engine.get("ok"))
|
|
engine_detail = _engine_unavailable_detail(
|
|
str(engine.get("detail") or "engine readiness unavailable")
|
|
)
|
|
engine_latency_ms = (time.perf_counter() - engine_started) * 1000
|
|
voice_ok = voice_service.is_available()
|
|
metrics = await _runtime_health_metrics(db_ok=db_ok)
|
|
metrics.engine_latency_ms = engine_latency_ms if engine_ok else None
|
|
pool_load = _pool_load(metrics)
|
|
session_load = _workload_load(metrics.active_sessions, 50)
|
|
review_load = _workload_load(metrics.pending_reviews, 50)
|
|
runtime_fallback_is_enabled = settings.environment == "dev"
|
|
db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down")
|
|
db_detail = (
|
|
"사용자, 세션, 리뷰 저장"
|
|
if db_ok
|
|
else (
|
|
"DB 연결 전까지 비영구 개발 런타임 기록 사용"
|
|
if runtime_fallback_is_enabled
|
|
else "DB 저장소에 연결할 수 없습니다"
|
|
)
|
|
)
|
|
db_metric = (
|
|
f"풀 {max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}"
|
|
if db_ok
|
|
else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단")
|
|
)
|
|
|
|
services = [
|
|
AdminServiceHealth(
|
|
key="engine",
|
|
name="응답 생성",
|
|
status="ok" if engine_ok else "down",
|
|
detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail,
|
|
metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요",
|
|
load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0,
|
|
),
|
|
AdminServiceHealth(
|
|
key="db",
|
|
name="영구 저장소",
|
|
status=db_status,
|
|
detail=db_detail,
|
|
metric=db_metric,
|
|
load=max(pool_load, session_load) if db_ok else 0.0,
|
|
),
|
|
AdminServiceHealth(
|
|
key="voice",
|
|
name="음성 입력",
|
|
status="ok" if voice_ok else "degraded",
|
|
detail="음성 입력과 재생",
|
|
metric="OpenAI 연결" if voice_ok else "설정 필요",
|
|
load=0.05 if voice_ok else 0.0,
|
|
),
|
|
AdminServiceHealth(
|
|
key="evaluation",
|
|
name="리뷰 생성",
|
|
status="ok" if engine_ok else "degraded",
|
|
detail="회기 종료 후 피드백 생성",
|
|
metric=f"대기 {metrics.pending_reviews}건",
|
|
load=review_load if engine_ok else 0.0,
|
|
),
|
|
AdminServiceHealth(
|
|
key="kb",
|
|
name="지식 검색",
|
|
status="ok" if db_ok else "degraded",
|
|
detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}",
|
|
metric=(
|
|
f"활성 세션 {metrics.active_sessions}건"
|
|
if db_ok
|
|
else "대기 중"
|
|
),
|
|
load=max(pool_load, session_load) if db_ok else 0.0,
|
|
),
|
|
]
|
|
|
|
return AdminHealthResponse(
|
|
status=_overall_status(services),
|
|
environment=settings.environment,
|
|
engine_mode=current_engine.engine_mode,
|
|
services=services,
|
|
)
|
|
|
|
|
|
@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."""
|
|
return await _current_engine_config()
|
|
|
|
|
|
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
|
|
async def patch_engine_config(
|
|
body: AdminEngineConfigPatch,
|
|
principal: AdminPrincipal,
|
|
) -> AdminEngineConfigResponse:
|
|
"""Persist engine settings for administrators."""
|
|
global _ENGINE_CONFIG
|
|
|
|
current = await _current_engine_config()
|
|
next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode)
|
|
next_url = _normalize_engine_url(body.engine_url or current.engine_url)
|
|
next_config = AdminEngineConfigResponse(
|
|
engine_mode=next_mode,
|
|
engine_url=next_url,
|
|
model=(body.model or current.model).strip(),
|
|
updated_by=principal.email,
|
|
updated_at=time.time(),
|
|
durable=False,
|
|
source="runtime_cache",
|
|
)
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.admin_engine_config (
|
|
id, engine_mode, engine_url, model, updated_by, updated_at
|
|
)
|
|
VALUES (TRUE, $1, $2, $3, $4, now())
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
engine_mode = EXCLUDED.engine_mode,
|
|
engine_url = EXCLUDED.engine_url,
|
|
model = EXCLUDED.model,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = now()
|
|
RETURNING engine_mode, engine_url, model, updated_by, updated_at
|
|
""",
|
|
next_config.engine_mode,
|
|
next_config.engine_url,
|
|
next_config.model,
|
|
principal.email,
|
|
)
|
|
next_config = _engine_config_from_row(row)
|
|
except Exception as exc:
|
|
if settings.environment != "dev":
|
|
raise HTTPException(
|
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="engine config persistence unavailable",
|
|
) from exc
|
|
_ENGINE_CONFIG = next_config
|
|
await engine_client.configure(
|
|
base_url=next_config.engine_url,
|
|
engine_mode=next_config.engine_mode,
|
|
default_model=next_config.model,
|
|
)
|
|
return next_config
|
|
|
|
|
|
@router.get("/users", response_model=AdminUsersResponse)
|
|
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
|
|
"""Return users observed by the server-side auth/session boundary."""
|
|
users, durable = await list_managed_users()
|
|
if not durable:
|
|
require_runtime_fallback_allowed("admin user list")
|
|
return AdminUsersResponse(
|
|
source="database" if durable else "server_session_registry",
|
|
durable=durable,
|
|
users=[await _admin_user_response(user, durable=durable) for user in users],
|
|
)
|
|
|
|
|
|
@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
body: AdminUserCreate,
|
|
principal: AdminPrincipal,
|
|
) -> AdminUserResponse:
|
|
"""Create or reactivate a managed user without requiring that user to log in first."""
|
|
user = await upsert_managed_user(
|
|
email=_normalize_email(body.email),
|
|
display_name=body.display_name,
|
|
role=body.role,
|
|
affiliation=body.affiliation,
|
|
cohort_ids=body.cohort_ids,
|
|
reactivate=True,
|
|
)
|
|
users, durable = await list_managed_users()
|
|
if not durable:
|
|
require_runtime_fallback_allowed("admin user create")
|
|
return await _admin_user_response(user, durable=durable)
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
|
|
async def patch_user(
|
|
user_id: str,
|
|
body: AdminUserPatch,
|
|
principal: AdminPrincipal,
|
|
) -> AdminUserResponse:
|
|
"""Update a server-known user's role/profile for the current API process."""
|
|
next_user = await update_managed_user(
|
|
user_id,
|
|
display_name=body.display_name,
|
|
role=body.role,
|
|
affiliation=body.affiliation,
|
|
cohort_ids=body.cohort_ids,
|
|
)
|
|
if next_user is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
|
users, durable = await list_managed_users()
|
|
if not durable:
|
|
require_runtime_fallback_allowed("admin user update")
|
|
return await _admin_user_response(next_user, durable=durable)
|
|
|
|
|
|
@router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse)
|
|
async def delete_user(
|
|
user_id: str,
|
|
principal: AdminPrincipal,
|
|
) -> AdminUserDeleteResponse:
|
|
"""Deactivate a managed user and revoke any active browser sessions."""
|
|
if user_id == principal.user_id:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin")
|
|
if not await deactivate_managed_user(user_id):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
|
return AdminUserDeleteResponse(ok=True, user_id=user_id)
|