vignette/apps/api/app/engine_client.py
Yun Chan 4b45d33142
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
관리자 AI 제공자 연결 기능 추가 (claude·codex·agy·openrouter 토큰 붙여넣기 연결)
관리자 /admin/ai 화면에서 provider 토큰(OAuth 액세스 토큰 또는 API 키)을
발급받아 붙여넣으면 DB에 암호화 저장되고, shared-secret으로 보호되는
게이트웨이 내부 엔드포인트로 push되어 실행 중인 엔진 컨테이너에 즉시
적용된다. NAS처럼 게이트웨이가 컨테이너로 도는 환경에서 CLI·로컬 PC
의존 없이 claude_api·openai·openrouter를 연결할 수 있다.

- 게이트웨이: openrouter 어댑터 신설(chat/completions), OAuth 토큰이면
  Anthropic Bearer 헤더, /internal/provider-credentials GET/POST와
  boot_id 기반 재동기화
- API: app.admin_provider_credential 테이블(idempotent DDL), stdlib
  HMAC-CTR+MAC 암호화 서비스(PROVIDER_CREDENTIAL_SECRET, 폴백
  SESSION_SECRET), /admin/providers CRUD·검증 라우트
- 웹: 제공자 연결 패널(저장·검증·해제, 토큰 힌트만 표시), 엔진 선택에
  OpenRouter 추가, api.gen.ts 재생성
- compose: api 서비스에 PROVIDER_CREDENTIAL_SECRET 전달(로컬·NAS)
2026-09-11 14:06:15 +09:00

369 lines
15 KiB
Python

"""엔진 게이트웨이 HTTP 클라이언트.
⚠️ 게이트웨이 자체(apps/api/engine_gateway/)는 람다가 직접 만든다. 여기는 *호출부*만.
게이트웨이가 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)를 게이트웨이에 넘기되,
text 는 *PII 마스킹 후(text_masked)* 만 보낸다 (R7/F-03, 마스킹은 호출 전 가드레일이 완료).
"""
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Optional
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,
)
class EngineError(RuntimeError):
"""게이트웨이 호출 실패. 라우트가 503/502 로 변환."""
class EngineClient:
"""ENGINE_URL 게이트웨이 비동기 클라이언트. 앱 수명주기 동안 1 인스턴스 재사용."""
def __init__(
self,
base_url: Optional[str] = None,
*,
shared_secret: Optional[str] = None,
) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/")
configured_secret = settings.engine_gateway_shared_secret.get_secret_value()
self._shared_secret = (
configured_secret if shared_secret is None else shared_secret
).strip()
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()
async def startup(self) -> None:
async with self._lock:
if self._client is None:
self._client = self._new_client()
def _new_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url,
headers=self._auth_headers(),
timeout=httpx.Timeout(
settings.engine_timeout,
connect=settings.engine_connect_timeout,
),
)
def _auth_headers(self) -> dict[str, str]:
if not self._shared_secret:
return {}
return {"X-Vignette-Engine-Token": self._shared_secret}
async def shutdown(self) -> None:
async with self._lock:
if self._client is not None:
await self._client.aclose()
self._client = None
async def configure(
self,
*,
base_url: 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)
async with self._lock:
url_changed = next_url != self.base_url
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()
await old_client.aclose()
def _payload(self, req: GenerateRequest) -> dict[str, Any]:
payload = req.model_dump(exclude_none=True)
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
def client(self) -> httpx.AsyncClient:
if self._client is None:
raise EngineError("EngineClient not started — call startup() in lifespan")
return self._client
async def health(self) -> bool:
return bool((await self.health_detail()).get("ok"))
async def _provider_health_detail(
self,
*,
provider: EngineProvider,
model: str | None = None,
reasoning_effort: ReasoningEffort | None = None,
) -> dict[str, Any]:
try:
params: dict[str, str] = {"provider": provider}
if model:
params["model"] = model
if reasoning_effort:
params["reasoning_effort"] = reasoning_effort
r = await self.client.get("/ready", params=params)
if r.status_code == 404:
live = await self.client.get("/health")
return {
# 게이트웨이 프로세스가 살아 있다는 사실만으로 특정 provider가
# 응답을 만들 수 있다고 판정하면 회기 화면이 거짓 GREEN이 된다.
"ok": False,
"detail": (
"게이트웨이는 응답하지만 공급자 준비상태 엔드포인트를 사용할 수 없음"
if live.status_code == 200
else "게이트웨이와 공급자 준비상태를 확인할 수 없음"
),
"status_code": live.status_code,
"cached": False,
}
payload: dict[str, Any] = {}
try:
decoded = r.json()
payload = decoded if isinstance(decoded, dict) else {}
except ValueError:
payload = {}
return {
"ok": r.status_code == 200 and bool(payload.get("ok", False)),
"detail": str(payload.get("detail") or r.text or "engine readiness failed"),
"status_code": r.status_code,
"cached": bool(payload.get("cached", False)),
}
except httpx.HTTPError as exc:
return {
"ok": False,
"detail": f"engine readiness transport error: {exc}",
"status_code": None,
"cached": False,
}
async def health_detail(self) -> dict[str, Any]:
"""관리자 기본 lane과 실시간 내담자 lane의 준비상태를 함께 반환한다.
내담자 턴은 ``live_client_provider``로, 평가·리뷰는 ``engine_mode``로
분리될 수 있으므로 기본 공급자 하나만 확인해서는 실제 응답 가능 여부를 알 수 없다.
"""
default_detail = await self._provider_health_detail(
provider=self.engine_mode,
model=self.default_model,
reasoning_effort=self.default_reasoning_effort,
)
live_provider = self.live_client_provider or self.engine_mode
if live_provider == self.engine_mode:
live_detail = dict(default_detail)
else:
# 관리자 기본 model/reasoning 값은 engine_mode 소속이므로 별도 client
# provider readiness에는 넘기지 않는다(_payload 계약과 동일).
live_detail = await self._provider_health_detail(provider=live_provider)
default_ok = bool(default_detail.get("ok"))
live_ok = bool(live_detail.get("ok"))
if not default_ok:
detail = (
f"기본 공급자 {self.engine_mode}: "
f"{default_detail.get('detail') or 'readiness failed'}"
)
status_code = default_detail.get("status_code")
elif not live_ok:
detail = (
f"실시간 내담자 공급자 {live_provider}: "
f"{live_detail.get('detail') or 'readiness failed'}"
)
status_code = live_detail.get("status_code")
else:
detail = str(live_detail.get("detail") or default_detail.get("detail") or "ready")
status_code = live_detail.get("status_code")
return {
"ok": default_ok and live_ok,
"detail": detail,
"status_code": status_code,
"cached": bool(default_detail.get("cached")) and bool(live_detail.get("cached")),
"default_engine": {
"provider": self.engine_mode,
**default_detail,
},
"live_client_engine": {
"provider": live_provider,
**live_detail,
},
}
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,
headers=self._auth_headers(),
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,
*,
timeout: float | None = None,
) -> GenerateResponse:
"""단발 생성."""
try:
kwargs: dict[str, Any] = {}
if timeout is not None:
kwargs["timeout"] = timeout
r = await self.client.post(
"/v1/generate",
json=self._payload(req),
**kwargs,
)
r.raise_for_status()
except httpx.HTTPStatusError as e:
raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e
except httpx.HTTPError as e:
raise EngineError(f"engine generate transport error: {e}") from e
return GenerateResponse.model_validate(r.json())
async def stream(self, req: StreamRequest) -> AsyncIterator[str]:
"""SSE 토큰 스트림 프록시.
게이트웨이 SSE(`text/event-stream`) 의 원시 non-empty line을 yield한다.
새 호출부는 raw line 대신 `stream_packets()`를 사용한다.
"""
try:
async with self.client.stream(
"POST", "/v1/stream", json=self._payload(req)
) as r:
r.raise_for_status()
async for line in r.aiter_lines():
if line:
yield line
except httpx.HTTPStatusError as e:
raise EngineError(f"engine stream {e.response.status_code}") from e
except httpx.HTTPError as e:
raise EngineError(f"engine stream transport error: {e}") from e
async def stream_packets(self, req: StreamRequest) -> AsyncIterator[EngineGatewaySsePacket]:
"""Decode gateway SSE into the shared token/done/error contract.
Keep wire-format parsing at the gateway client boundary so app services
do not depend on raw SSE line structure. A future Node.js gateway should
only need to preserve `app.contracts.engine_gateway`.
"""
decoder = EngineGatewaySseLineDecoder()
async for raw in self.stream(req):
packet = decoder.feed_line(raw)
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
async def provider_credentials_status(self) -> dict[str, Any]:
"""게이트웨이 프로세스에 주입된 provider 자격증명 상태(boot_id 포함)."""
try:
response = await self.client.get("/internal/provider-credentials")
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else {}
except (httpx.HTTPError, ValueError) as exc:
raise EngineError(f"provider credentials status unavailable: {exc}") from exc
async def push_provider_credentials(
self, providers: dict[str, dict[str, str]]
) -> dict[str, Any]:
"""관리자가 저장한 provider 토큰을 게이트웨이 프로세스 환경에 주입한다."""
try:
response = await self.client.post(
"/internal/provider-credentials",
json={"providers": providers},
)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else {}
except httpx.HTTPStatusError as exc:
raise EngineError(
f"provider credentials push {exc.response.status_code}: {exc.response.text}"
) from exc
except (httpx.HTTPError, ValueError) as exc:
raise EngineError(f"provider credentials push unavailable: {exc}") from exc
# 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
engine_client = EngineClient()