Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue