Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -11,17 +11,21 @@ Vignette 엔진 게이트웨이 — 로컬 claude -p(Opus 4.8) 상주 멀티턴
import asyncio
import json
import os
import time
import uuid
from typing import Any, Literal, Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
FALLBACK_MODEL = os.environ.get("ENGINE_FALLBACK_MODEL", "")
DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0"))
READY_TTL_SECONDS = float(os.environ.get("ENGINE_READY_TTL_SECONDS", "30"))
READY_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_READY_TIMEOUT_SECONDS", "20"))
READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5"))
BASE_ARGS = [
"-p",
@ -32,13 +36,26 @@ BASE_ARGS = [
]
def _model_override(model: Optional[str]) -> Optional[str]:
value = (model or "").strip()
if not value or value == "gateway-default":
return None
return value
class EngineSession:
"""claude -p 상주 프로세스 1개 = 상담 회기 1개."""
def __init__(self, system_prompt: str | None = None, budget: float = DEFAULT_BUDGET):
def __init__(
self,
system_prompt: str | None = None,
budget: float = DEFAULT_BUDGET,
model: str | None = None,
):
self.id = uuid.uuid4().hex
self.system_prompt = system_prompt
self.budget = budget
self.model = _model_override(model)
self.proc: asyncio.subprocess.Process | None = None
self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복)
self.cost_usd = 0.0
@ -46,8 +63,9 @@ class EngineSession:
async def start(self) -> None:
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
if DEFAULT_MODEL:
args += ["--model", DEFAULT_MODEL]
model = self.model or DEFAULT_MODEL
if model:
args += ["--model", model]
if FALLBACK_MODEL:
args += ["--fallback-model", FALLBACK_MODEL]
if self.system_prompt:
@ -93,12 +111,19 @@ class EngineSession:
result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
self.cost_usd = result.get("total_cost_usd", self.cost_usd)
self.turns += 1
error_detail = (
result.get("error")
or result.get("result")
or (result.get("errors") or [None])[0]
or "".join(text_parts)
or None
)
return {
"text": "".join(text_parts),
"cost_usd": self.cost_usd,
"turns": self.turns,
"is_error": result.get("is_error", False),
"error": (result.get("errors") or [None])[0],
"error": error_detail,
}
async def turn_stream(self, content: str, timeout: float = 600.0):
@ -153,13 +178,20 @@ class EngineSession:
elif t == "result":
self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
self.turns += 1
error_detail = (
obj.get("error")
or obj.get("result")
or (obj.get("errors") or [None])[0]
or emitted
or None
)
yield {
"type": "done",
"text": emitted,
"cost_usd": self.cost_usd,
"turns": self.turns,
"is_error": obj.get("is_error", False),
"error": (obj.get("errors") or [None])[0],
"error": error_detail,
}
return
@ -176,6 +208,8 @@ class EngineSession:
SESSIONS: dict[str, EngineSession] = {}
_READY_CACHE: dict[str, Any] = {"checked_at": 0.0, "ok": False, "detail": "not checked"}
_READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway")
@ -193,6 +227,62 @@ async def health():
return {"ok": True, "engine": "claude_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)}
def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse:
ok = bool(_READY_CACHE.get("ok"))
return JSONResponse(
{
"ok": ok,
"engine": "claude_p",
"model": DEFAULT_MODEL or "default(opus-4-8)",
"sessions": len(SESSIONS),
"detail": _READY_CACHE.get("detail"),
"age_seconds": round(max(0.0, age_seconds), 3),
"cached": cached,
},
status_code=200 if ok else 503,
)
@app.get("/ready")
async def ready(force: bool = False):
"""Prove that claude -p can complete a real generation.
/health is shallow process liveness. This endpoint catches the installed-but-
not-authenticated CLI state before a learner reaches POST /sessions/:id/turn.
"""
age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0)
if not force and age < READY_TTL_SECONDS:
return _ready_response(cached=True, age_seconds=age)
async with _READY_LOCK:
age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0)
if not force and age < READY_TTL_SECONDS:
return _ready_response(cached=True, age_seconds=age)
probe = EngineSession(
system_prompt="You are a readiness probe. Reply with exactly OK.",
budget=READY_BUDGET_USD,
)
ok = False
detail = "unknown readiness failure"
try:
await probe.start()
result = await probe.turn("Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS)
if result.get("is_error"):
detail = str(result.get("error") or "engine returned an error")
else:
text = str(result.get("text") or "").strip()
ok = bool(text)
detail = text or "empty engine response"
except Exception as exc:
detail = str(exc)
finally:
await probe.close()
_READY_CACHE.update({"checked_at": time.monotonic(), "ok": ok, "detail": detail})
return _ready_response(cached=False)
@app.post("/session")
async def create_session(req: CreateReq):
s = EngineSession(system_prompt=req.system_prompt, budget=req.budget_usd or DEFAULT_BUDGET)
@ -291,12 +381,18 @@ async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[Engi
반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다.
"""
requested_model = _model_override(req.model)
if req.session_id and req.session_id in SESSIONS:
s = SESSIONS[req.session_id]
if s.proc is not None and s.proc.returncode is None:
return s, False
if requested_model is None or (s.model or DEFAULT_MODEL) == requested_model:
return s, False
# 단발(또는 죽은 세션) → 1회성 세션
s = EngineSession(system_prompt=system_prompt or None, budget=DEFAULT_BUDGET)
s = EngineSession(
system_prompt=system_prompt or None,
budget=DEFAULT_BUDGET,
model=requested_model,
)
await s.start()
return s, True
@ -327,7 +423,7 @@ async def v1_generate(req: GwGenerateReq):
structured = None # 파싱 실패는 호출부가 text 로 폴백
return {
"text": text,
"model": DEFAULT_MODEL or "claude-opus-4-8",
"model": s.model or DEFAULT_MODEL or "claude-opus-4-8",
"provider": "claude_cli",
"tokens_in": 0,
"tokens_out": 0,

View file

@ -0,0 +1,125 @@
import asyncio
import unittest
from unittest.mock import patch
from engine_gateway import gateway
class _FakeStdin:
def close(self):
pass
class _FakeProcess:
def __init__(self):
self.returncode = None
self.stdin = _FakeStdin()
self.stdout = None
self.stderr = None
async def wait(self):
self.returncode = 0
def kill(self):
self.returncode = -9
def _capture_subprocess():
captured = []
async def fake_create_subprocess_exec(*args, **kwargs):
captured.append(args)
return _FakeProcess()
return captured, patch.object(
gateway.asyncio,
"create_subprocess_exec",
fake_create_subprocess_exec,
)
def _request(model=None, session_id=None):
return gateway.GwGenerateReq(
ai_role="client",
messages=[gateway.GwMessage(role="user", content="hello")],
model=model,
session_id=session_id,
)
def _model_arg(args):
if "--model" not in args:
return None
return args[args.index("--model") + 1]
class GatewayModelTest(unittest.TestCase):
def setUp(self):
gateway.SESSIONS.clear()
def tearDown(self):
gateway.SESSIONS.clear()
def test_resolve_session_uses_request_model_for_claude_cli(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", "env-default"),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(model=" request-model "), "system prompt")
)
try:
self.assertIs(ephemeral, True)
self.assertEqual(session.model, "request-model")
self.assertEqual(_model_arg(captured[0]), "request-model")
self.assertIn("--append-system-prompt", captured[0])
finally:
asyncio.run(session.close())
def test_resolve_session_preserves_default_model_for_gateway_default(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", "env-default"),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(model="gateway-default"), "")
)
try:
self.assertIs(ephemeral, True)
self.assertIsNone(session.model)
self.assertEqual(_model_arg(captured[0]), "env-default")
finally:
asyncio.run(session.close())
def test_resolve_session_does_not_reuse_session_with_different_model(self):
captured, process_patch = _capture_subprocess()
existing = gateway.EngineSession(model="old-model")
existing.proc = _FakeProcess()
gateway.SESSIONS["sid"] = existing
with (
patch.object(gateway, "DEFAULT_MODEL", ""),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(model="new-model", session_id="sid"), "")
)
try:
self.assertIs(ephemeral, True)
self.assertIsNot(session, existing)
self.assertEqual(_model_arg(captured[0]), "new-model")
self.assertIs(gateway.SESSIONS["sid"], existing)
finally:
asyncio.run(session.close())
if __name__ == "__main__":
unittest.main()