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

@ -1,12 +1,14 @@
"""엔진 게이트웨이 HTTP 클라이언트.
게이트웨이 자체(apps/api/engine_gateway/) 람다가 직접 만든다. 여기는 *호출부*.
게이트웨이가 provider 라우팅(claude_api/claude_cli/openai/solar)·캐싱·상주 claude -p 풀을
흡수한다(마스터플랜 §0, R1). 백엔드는 ENGINE_URL HTTP 호출만 한다.
게이트웨이가 provider 라우팅(claude_cli/claude_api/codex_cli/agy_cli/openai/solar)·
모델 탐색·캐싱·상주 claude -p 풀을 흡수한다(마스터플랜 §0, R1).
백엔드는 ENGINE_URL로 HTTP 호출만 한다.
계약 (람다와 합의할 게이트웨이 API):
POST {ENGINE_URL}/v1/generate 단발 생성 (평가 deep-loop )
POST {ENGINE_URL}/v1/stream SSE 토큰 스트림 (내담자 AI 응답)
GET {ENGINE_URL}/v1/capabilities provider별 사용 가능 모델·추론 강도
GET {ENGINE_URL}/health
요청 바디는 3-AI 역할별 system 레이어(L0~L6, 설계서 §1.2) 게이트웨이에 넘기되,
@ -23,11 +25,14 @@ import httpx
from .config import settings
from .contracts.engine_gateway import (
AIRole as AIRole,
EngineCapabilitiesResponse,
EngineMessage as EngineMessage,
EngineGatewaySseLineDecoder,
EngineGatewaySsePacket,
EngineProvider,
GenerateRequest,
GenerateResponse,
ReasoningEffort,
StreamRequest,
normalize_engine_gateway_model,
)
@ -43,7 +48,9 @@ class EngineClient:
def __init__(self, base_url: Optional[str] = None) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/")
self.engine_mode = settings.engine_mode
self.live_client_provider: Optional[EngineProvider] = settings.live_client_provider
self.default_model: Optional[str] = None
self.default_reasoning_effort: Optional[ReasoningEffort] = None
self._client: Optional[httpx.AsyncClient] = None
self._lock = asyncio.Lock()
@ -71,8 +78,9 @@ class EngineClient:
self,
*,
base_url: str,
engine_mode: str,
engine_mode: EngineProvider,
default_model: Optional[str] = None,
default_reasoning_effort: Optional[ReasoningEffort] = None,
) -> None:
next_url = base_url.rstrip("/")
next_model = normalize_engine_gateway_model(default_model)
@ -81,6 +89,7 @@ class EngineClient:
self.base_url = next_url
self.engine_mode = engine_mode
self.default_model = next_model
self.default_reasoning_effort = default_reasoning_effort
if self._client is not None and url_changed:
old_client = self._client
self._client = self._new_client()
@ -88,8 +97,22 @@ class EngineClient:
def _payload(self, req: GenerateRequest) -> dict[str, Any]:
payload = req.model_dump(exclude_none=True)
if self.default_model and "model" not in payload:
provider = self.engine_mode
if req.ai_role == "client" and req.session_id and self.live_client_provider:
provider = self.live_client_provider
if "provider" not in payload:
payload["provider"] = provider
# 관리자 기본 모델/추론 강도는 그 provider에 속한 값이다. 실시간 lane이
# 다른 provider면 잘못된 모델 slug를 넘기지 않고 해당 provider 기본값을 쓴다.
same_provider = payload["provider"] == self.engine_mode
if same_provider and self.default_model and "model" not in payload:
payload["model"] = self.default_model
if (
same_provider
and self.default_reasoning_effort
and "reasoning_effort" not in payload
):
payload["reasoning_effort"] = self.default_reasoning_effort
return payload
@property
@ -103,7 +126,12 @@ class EngineClient:
async def health_detail(self) -> dict[str, Any]:
try:
r = await self.client.get("/ready")
params: dict[str, str] = {"provider": self.engine_mode}
if self.default_model:
params["model"] = self.default_model
if self.default_reasoning_effort:
params["reasoning_effort"] = self.default_reasoning_effort
r = await self.client.get("/ready", params=params)
if r.status_code == 404:
live = await self.client.get("/health")
return {
@ -129,6 +157,33 @@ class EngineClient:
"status_code": None,
}
async def capabilities(
self,
*,
provider: EngineProvider,
base_url: str | None = None,
force: bool = False,
) -> EngineCapabilitiesResponse:
target_url = (base_url or self.base_url).rstrip("/")
params = {"provider": provider, "force": str(force).lower()}
try:
if target_url == self.base_url:
response = await self.client.get("/v1/capabilities", params=params)
else:
async with httpx.AsyncClient(
base_url=target_url,
timeout=httpx.Timeout(30, connect=settings.engine_connect_timeout),
) as client:
response = await client.get("/v1/capabilities", params=params)
response.raise_for_status()
return EngineCapabilitiesResponse.model_validate(response.json())
except httpx.HTTPStatusError as exc:
raise EngineError(
f"engine capabilities {exc.response.status_code}: {exc.response.text}"
) from exc
except (httpx.HTTPError, ValueError) as exc:
raise EngineError(f"engine capabilities unavailable: {exc}") from exc
async def generate(self, req: GenerateRequest) -> GenerateResponse:
"""단발 생성."""
try:
@ -172,6 +227,18 @@ class EngineClient:
if packet is not None:
yield packet
async def close_session(self, session_id: str) -> bool:
"""회기 종료 시 게이트웨이의 상주 페르소나 프로세스를 회수한다."""
if not session_id:
return False
try:
response = await self.client.delete(f"/session/{session_id}")
response.raise_for_status()
return bool(response.json().get("closed"))
except (httpx.HTTPError, ValueError):
# DB 회기 종료 성공을 게이트웨이 정리 실패 때문에 되돌리지는 않는다.
return False
# 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
engine_client = EngineClient()