feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import time
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Literal
from typing import Annotated, Literal, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
@ -25,7 +25,14 @@ from ..auth_sessions import (
upsert_managed_user,
)
from ..config import settings
from ..contracts.engine_gateway import ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL
from ..contracts.engine_gateway import (
ENGINE_PROVIDER_DEFAULTS,
ENGINE_PROVIDERS,
ENGINE_REASONING_EFFORTS,
EngineCapabilitiesResponse,
EngineProvider,
ReasoningEffort,
)
from ..db import acquire, get_pool, healthcheck
from ..deps import Principal, require_admin_access
from ..engine_client import engine_client
@ -269,9 +276,10 @@ class AdminTicketsResponse(BaseModel):
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_mode: EngineProvider
engine_url: str
model: str
reasoning_effort: ReasoningEffort | None = None
updated_by: str | None = None
updated_at: float | None = None
durable: bool = False
@ -282,6 +290,7 @@ class AdminEngineConfigPatch(BaseModel):
engine_mode: str | None = None
engine_url: str | None = None
model: str | None = None
reasoning_effort: str | None = None
class AdminTicketPatch(BaseModel):
@ -977,7 +986,7 @@ class AdminUserDeleteResponse(BaseModel):
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
ENGINE_MODES = set(ENGINE_PROVIDERS)
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
@ -992,23 +1001,37 @@ def _normalize_email(value: str) -> str:
def _default_engine_config() -> AdminEngineConfigResponse:
default_model, default_effort = ENGINE_PROVIDER_DEFAULTS[settings.engine_mode]
return AdminEngineConfigResponse(
engine_mode=settings.engine_mode,
engine_url=settings.engine_url,
model=ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
model=default_model,
reasoning_effort=default_effort,
durable=False,
source="runtime_default",
)
def _normalize_engine_mode(value: str) -> str:
def _normalize_engine_mode(value: str) -> EngineProvider:
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
return cast(EngineProvider, mode)
def _normalize_reasoning_effort(value: str | None) -> ReasoningEffort | None:
effort = (value or "").strip().lower()
if not effort:
return None
if effort not in ENGINE_REASONING_EFFORTS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported reasoning effort {value}",
)
return cast(ReasoningEffort, effort)
def _normalize_engine_url(value: str) -> str:
@ -1071,6 +1094,7 @@ def _engine_config_from_row(row) -> AdminEngineConfigResponse:
engine_mode=_normalize_engine_mode(row["engine_mode"]),
engine_url=_normalize_engine_url(row["engine_url"]),
model=row["model"],
reasoning_effort=_normalize_reasoning_effort(row.get("reasoning_effort")),
updated_by=row["updated_by"],
updated_at=_updated_at_ts(row["updated_at"]),
durable=True,
@ -1266,7 +1290,7 @@ async def _current_engine_config() -> AdminEngineConfigResponse:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT engine_mode, engine_url, model, updated_by, updated_at
SELECT engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
FROM app.admin_engine_config
WHERE id = TRUE
"""
@ -1286,6 +1310,7 @@ async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
base_url=config.engine_url,
engine_mode=config.engine_mode,
default_model=config.model,
default_reasoning_effort=config.reasoning_effort,
)
return config
@ -1752,6 +1777,74 @@ async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigRespo
return await _current_engine_config()
@router.get("/engine-capabilities", response_model=EngineCapabilitiesResponse)
async def get_engine_capabilities(
principal: AdminPrincipal,
engine_mode: str | None = Query(default=None),
engine_url: str | None = Query(default=None),
force: bool = Query(default=False),
) -> EngineCapabilitiesResponse:
"""Return gateway-discovered models and reasoning levels for one provider."""
current = await _current_engine_config()
provider = _normalize_engine_mode(engine_mode or current.engine_mode)
capability_url = (
_normalize_engine_url(engine_url)
if engine_url is not None
else current.engine_url
)
try:
return await engine_client.capabilities(
provider=provider,
base_url=capability_url,
force=force,
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"engine capabilities unavailable: {exc}",
) from exc
async def _validate_engine_selection(
*,
provider: EngineProvider,
engine_url: str,
model: str,
reasoning_effort: ReasoningEffort | None,
) -> None:
try:
capabilities = await engine_client.capabilities(
provider=provider,
base_url=engine_url,
force=True,
)
except Exception as exc:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"선택한 엔진의 모델 목록을 검증할 수 없습니다: {exc}",
) from exc
if not capabilities.available:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=capabilities.detail or "선택한 엔진을 사용할 수 없습니다.",
)
selected = next((option for option in capabilities.models if option.id == model), None)
if selected is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{provider}에서 사용할 수 없는 모델입니다: {model}",
)
if (
reasoning_effort is not None
and reasoning_effort not in selected.reasoning_efforts
):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{model}에서 사용할 수 없는 추론 강도입니다: {reasoning_effort}",
)
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
async def patch_engine_config(
body: AdminEngineConfigPatch,
@ -1763,10 +1856,28 @@ async def patch_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_model = (body.model or current.model).strip()
if not next_model:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="model is required",
)
next_effort = _normalize_reasoning_effort(
body.reasoning_effort
if "reasoning_effort" in body.model_fields_set
else current.reasoning_effort
)
await _validate_engine_selection(
provider=next_mode,
engine_url=next_url,
model=next_model,
reasoning_effort=next_effort,
)
next_config = AdminEngineConfigResponse(
engine_mode=next_mode,
engine_url=next_url,
model=(body.model or current.model).strip(),
model=next_model,
reasoning_effort=next_effort,
updated_by=principal.email,
updated_at=time.time(),
durable=False,
@ -1778,20 +1889,22 @@ async def patch_engine_config(
row = await conn.fetchrow(
"""
INSERT INTO app.admin_engine_config (
id, engine_mode, engine_url, model, updated_by, updated_at
id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
)
VALUES (TRUE, $1, $2, $3, $4, now())
VALUES (TRUE, $1, $2, $3, $4, $5, now())
ON CONFLICT (id) DO UPDATE SET
engine_mode = EXCLUDED.engine_mode,
engine_url = EXCLUDED.engine_url,
model = EXCLUDED.model,
reasoning_effort = EXCLUDED.reasoning_effort,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING engine_mode, engine_url, model, updated_by, updated_at
RETURNING engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
""",
next_config.engine_mode,
next_config.engine_url,
next_config.model,
next_config.reasoning_effort,
principal.email,
)
next_config = _engine_config_from_row(row)
@ -1806,6 +1919,7 @@ async def patch_engine_config(
base_url=next_config.engine_url,
engine_mode=next_config.engine_mode,
default_model=next_config.model,
default_reasoning_effort=next_config.reasoning_effort,
)
return next_config