공개 런타임 승격 체인이 psutil로 이전 프로세스 환경을 통째로 이식하는 과정에서 SystemRoot 가 유실됐고, Go 계열 CLI(agy)는 시스템 인증서 풀/홈 해석에 SystemRoot 가 필요해 agy models 가 조용히 빈 목록을 반환했다(관리자 AI 운영 화면의 'Agy가 선택 가능한 모델을 반환하지 않았습니다' 두 번째 원인). - _cli_subprocess_env(): 상속 환경에서 빠진 SystemRoot/SystemDrive/ComSpec 만 기본값으로 백필해 모든 CLI 스폰(_run_process·codex app-server·agy stream·claude 세션)에 적용. os.environ 의 Windows 대문자 정규화를 고려한 대소문자 무시 조회 - Set-CompleteProcessEnvironment: 이식본에 빠진 Windows 필수 키를 Machine 스코프 표준값으로 병합해 승격 체인 자체의 유실을 원천 보강 - 검증: 신규 2단위 RED→GREEN, engine_gateway 65 passed, app 924 passed, PS 5.1 parser OK, SystemRoot 제거 환경에서 실제 agy CLI 14모델 live 조회 확인
957 lines
38 KiB
Python
957 lines
38 KiB
Python
"""
|
|
Vignette 엔진 게이트웨이 — 로컬 claude -p(Opus 4.8) 상주 멀티턴 풀.
|
|
|
|
컨테이너 밖(호스트)에서 실행한다. api 컨테이너가 ENGINE_URL(예: http://host.docker.internal:9099)로 HTTP 호출.
|
|
회기당 1 EngineSession = claude -p 1 프로세스 상주. 페르소나 시스템프롬프트를 고정하고 발화마다 stdin 주입.
|
|
턴 간 컨텍스트 유지 + prompt caching 재사용(2026-06-25 실증, reference_claude_p_resident_engine).
|
|
|
|
실행:
|
|
cd apps/api && uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from app.contracts.engine_gateway import (
|
|
AIRole,
|
|
ENGINE_GATEWAY_SSE_DONE,
|
|
ENGINE_GATEWAY_SSE_ERROR,
|
|
ENGINE_GATEWAY_SSE_TOKEN,
|
|
EngineCapabilitiesResponse,
|
|
EngineMessage as GwMessage,
|
|
EngineProvider,
|
|
GenerateResponse,
|
|
GenerateRequest as GwGenerateReq,
|
|
ReasoningEffort,
|
|
StreamDoneEvent,
|
|
StreamErrorEvent,
|
|
StreamTokenEvent,
|
|
normalize_engine_gateway_model,
|
|
sse_frame,
|
|
)
|
|
from engine_gateway.provider_registry import (
|
|
ProviderError,
|
|
_cli_subprocess_env,
|
|
discover_capabilities,
|
|
generate_with_provider,
|
|
stream_with_provider,
|
|
)
|
|
|
|
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"))
|
|
# 실패 결과를 성공과 같은 TTL로 캐시하면 콜드 프로브 1회 타임아웃이 운영 TTL(1800초)
|
|
# 내내 공개 API를 engine=false로 오염시킨다(2026-08-18 06:30 KST 30분 장애). 실패만
|
|
# 짧게 캐시해 재프로브하되, 진짜 장애 중 프로브 폭풍은 이 TTL이 막는다.
|
|
READY_FAILURE_TTL_SECONDS = float(
|
|
os.environ.get("ENGINE_READY_FAILURE_TTL_SECONDS", "30")
|
|
)
|
|
# reasoning_effort=high 콜드 스폰은 정상 상태에서도 20초를 넘길 수 있다(2026-08-18 실측).
|
|
READY_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_READY_TIMEOUT_SECONDS", "45"))
|
|
READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5"))
|
|
# 단발 생성(/v1/generate) 턴 타임아웃 — 페르소나 초안 생성 같은 대형 구조화 출력은
|
|
# 120초를 넘길 수 있어 설정 가능하게 한다(2026-07-15). 호출부(app ENGINE_TIMEOUT)와 정합 필요.
|
|
GENERATE_TURN_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_GENERATE_TIMEOUT_SECONDS", "300"))
|
|
SESSION_IDLE_TTL_SECONDS = float(os.environ.get("ENGINE_SESSION_IDLE_TTL_SECONDS", "3600"))
|
|
MAX_RESIDENT_SESSIONS = max(1, int(os.environ.get("ENGINE_MAX_RESIDENT_SESSIONS", "24")))
|
|
GATEWAY_PROVIDER = "claude_cli"
|
|
GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
|
|
# claude -p 자식의 stderr 보존량. 인증 만료/플래그 오류처럼 CLI가 stdout 한 줄도 못 내고
|
|
# 죽는 경우 원인은 stderr에만 남는다. 이걸 버리면 게이트웨이는 "empty engine response"만
|
|
# 보고하고 운영자는 프로세스 환경을 재현해야 원인을 안다(2026-08-07 공개 런타임 사고).
|
|
STDERR_TAIL_LINES = 8
|
|
STDERR_TAIL_CHARS = 400
|
|
ENGINE_TOKEN_HEADER = "X-Vignette-Engine-Token"
|
|
_SECRET_PLACEHOLDER_PREFIXES = ("change-me", "replace-with", "dummy", "example")
|
|
|
|
|
|
def _load_gateway_shared_secret() -> str:
|
|
value = os.environ.get("ENGINE_GATEWAY_SHARED_SECRET", "").strip()
|
|
if not value:
|
|
return ""
|
|
if len(value) < 32 or value.lower().startswith(_SECRET_PLACEHOLDER_PREFIXES):
|
|
raise RuntimeError(
|
|
"ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value "
|
|
"containing at least 32 characters"
|
|
)
|
|
return value
|
|
|
|
|
|
ENGINE_GATEWAY_SHARED_SECRET = _load_gateway_shared_secret()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class GatewayPromptParts:
|
|
system_prompt: str
|
|
user_payload: str
|
|
current_user_payload: str
|
|
|
|
|
|
BASE_ARGS = [
|
|
"-p",
|
|
"--input-format", "stream-json",
|
|
"--output-format", "stream-json",
|
|
"--include-partial-messages",
|
|
"--verbose",
|
|
# 상담 축어록은 게이트웨이 프로세스 수명 안에서만 유지한다. Claude CLI의 로컬
|
|
# 세션 파일로 이중 저장하지 않아 개인정보 노출과 매 턴 디스크 I/O를 줄인다.
|
|
"--no-session-persistence",
|
|
"--dangerously-skip-permissions",
|
|
# 페르소나 격리: cwd/env/git status/메모리(CLAUDE.md) 등 per-machine 섹션을 시스템프롬프트에서
|
|
# 제거 → 내담자 AI가 자신이 개발 환경(Claude Code/Vignette repo) 안에 있음을 알아채 캐릭터를
|
|
# 깨는 것을 차단. (시스템프롬프트는 아래에서 --system-prompt 로 페르소나만 '교체' 주입.)
|
|
"--exclude-dynamic-system-prompt-sections",
|
|
]
|
|
|
|
|
|
def _usage_int(payload: dict[str, Any], *keys: str) -> int:
|
|
for key in keys:
|
|
value = payload.get(key)
|
|
if value is None:
|
|
continue
|
|
try:
|
|
return max(0, int(value))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return 0
|
|
|
|
|
|
def _claude_result_tokens(result: dict[str, Any]) -> tuple[int, int]:
|
|
"""Claude CLI result의 전체 agent-tree 토큰을 기존 원장 두 컬럼으로 정규화한다.
|
|
|
|
modelUsage/model_usage가 있으면 서브에이전트를 포함한 모델별 사용량을 합산한다.
|
|
입력 토큰은 공식 계약에 맞춰 비캐시 입력 + cache read + cache creation이다.
|
|
"""
|
|
|
|
raw_model_usage = result.get("modelUsage") or result.get("model_usage")
|
|
usage_rows = (
|
|
[row for row in raw_model_usage.values() if isinstance(row, dict)]
|
|
if isinstance(raw_model_usage, dict)
|
|
else []
|
|
)
|
|
if not usage_rows:
|
|
usage = result.get("usage")
|
|
usage_rows = [usage] if isinstance(usage, dict) else []
|
|
|
|
tokens_in = 0
|
|
tokens_out = 0
|
|
for usage in usage_rows:
|
|
tokens_in += _usage_int(usage, "inputTokens", "input_tokens")
|
|
tokens_in += _usage_int(
|
|
usage,
|
|
"cacheReadInputTokens",
|
|
"cache_read_input_tokens",
|
|
)
|
|
tokens_in += _usage_int(
|
|
usage,
|
|
"cacheCreationInputTokens",
|
|
"cache_creation_input_tokens",
|
|
)
|
|
tokens_out += _usage_int(usage, "outputTokens", "output_tokens")
|
|
return tokens_in, tokens_out
|
|
|
|
|
|
class EngineSession:
|
|
"""claude -p 상주 프로세스 1개 = 상담 회기 1개."""
|
|
|
|
def __init__(
|
|
self,
|
|
system_prompt: str | None = None,
|
|
budget: float = DEFAULT_BUDGET,
|
|
model: str | None = None,
|
|
reasoning_effort: ReasoningEffort | None = None,
|
|
):
|
|
self.id = uuid.uuid4().hex
|
|
self.system_prompt = system_prompt
|
|
self.budget = budget
|
|
self.model = normalize_engine_gateway_model(model)
|
|
self.reasoning_effort = reasoning_effort
|
|
self.proc: asyncio.subprocess.Process | None = None
|
|
self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복)
|
|
self.cost_usd = 0.0
|
|
self.turns = 0
|
|
self.last_used_at = time.monotonic()
|
|
# stderr는 계속 비워야 한다. PIPE를 열고 아무도 읽지 않으면 자식이 파이프 버퍼가
|
|
# 찬 시점에 블록된다. 겸사겸사 마지막 몇 줄을 진단용으로 남긴다.
|
|
self._stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
|
|
self._stderr_task: asyncio.Task | None = None
|
|
|
|
async def _drain_stderr(self) -> None:
|
|
proc = self.proc
|
|
if proc is None or proc.stderr is None:
|
|
return
|
|
try:
|
|
while True:
|
|
line = await proc.stderr.readline()
|
|
if not line:
|
|
return
|
|
text = line.decode("utf-8", "replace").strip()
|
|
if text:
|
|
self._stderr_tail.append(text)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
return
|
|
|
|
def stderr_tail(self) -> str:
|
|
"""자식 CLI가 마지막으로 남긴 stderr 요약(진단 표면용)."""
|
|
joined = " | ".join(self._stderr_tail)
|
|
if len(joined) > STDERR_TAIL_CHARS:
|
|
return "…" + joined[-STDERR_TAIL_CHARS:]
|
|
return joined
|
|
|
|
async def failure_detail(self, fallback: str) -> str:
|
|
"""텍스트를 못 받았을 때 원인을 최대한 좁힌 detail을 만든다."""
|
|
task = self._stderr_task
|
|
if task is not None and not task.done():
|
|
# 자식이 즉시 죽은 경우 stdout EOF가 stderr 드레인보다 먼저 도착할 수 있다.
|
|
# shield로 감싸 세션이 계속 살아있는 경우의 드레인을 취소하지 않는다.
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=0.5)
|
|
except Exception:
|
|
pass
|
|
parts: list[str] = []
|
|
code = self.proc.returncode if self.proc is not None else None
|
|
if code is not None:
|
|
parts.append(f"exit={code}")
|
|
tail = self.stderr_tail()
|
|
if tail:
|
|
parts.append(tail)
|
|
if not parts:
|
|
return fallback
|
|
return f"{fallback} ({' · '.join(parts)})"
|
|
|
|
async def start(self) -> None:
|
|
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
|
|
model = self.model or DEFAULT_MODEL
|
|
if model:
|
|
args += ["--model", model]
|
|
if self.reasoning_effort:
|
|
args += ["--effort", self.reasoning_effort]
|
|
if FALLBACK_MODEL:
|
|
args += ["--fallback-model", FALLBACK_MODEL]
|
|
if self.system_prompt:
|
|
# APPEND(기본 코딩 어시스턴트 프롬프트에 덧붙임) 대신 REPLACE → 페르소나가 유일한
|
|
# 정체성. 기본 Claude Code 시스템프롬프트가 남으면 내담자가 어시스턴트로 작동/캐릭터 붕괴.
|
|
args += ["--system-prompt", self.system_prompt]
|
|
self.proc = await asyncio.create_subprocess_exec(
|
|
*args,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_cli_subprocess_env(),
|
|
)
|
|
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
|
|
async def turn(self, content: str, timeout: float = 120.0) -> dict:
|
|
if self.proc is None or self.proc.returncode is not None:
|
|
raise RuntimeError("engine session not running")
|
|
async with self.lock:
|
|
msg = json.dumps(
|
|
{"type": "user", "message": {"role": "user", "content": content}},
|
|
ensure_ascii=False,
|
|
)
|
|
self.proc.stdin.write((msg + "\n").encode("utf-8"))
|
|
await self.proc.stdin.drain()
|
|
|
|
text_parts: list[str] = []
|
|
|
|
async def _read_until_result() -> dict:
|
|
# 한 턴: system(init/hook) 라인 무시 → assistant 텍스트 누적 → result 에서 종료
|
|
while True:
|
|
line = await self.proc.stdout.readline()
|
|
if not line:
|
|
return {}
|
|
try:
|
|
obj = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
t = obj.get("type")
|
|
if t == "assistant":
|
|
for c in obj.get("message", {}).get("content", []):
|
|
if c.get("type") == "text":
|
|
text_parts.append(c["text"])
|
|
elif t == "result":
|
|
return obj
|
|
|
|
result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
|
|
self.last_used_at = time.monotonic()
|
|
self.cost_usd = result.get("total_cost_usd", self.cost_usd)
|
|
tokens_in, tokens_out = _claude_result_tokens(result)
|
|
self.turns += 1
|
|
assistant_text = "".join(text_parts)
|
|
terminal_result = result.get("result")
|
|
terminal_text = terminal_result if isinstance(terminal_result, str) else ""
|
|
# Claude CLI stream-json can emit the complete successful answer only
|
|
# on the terminal result event. Prefer assistant events when present
|
|
# (they preserve streaming semantics), but never discard a terminal-only
|
|
# structured response.
|
|
response_text = assistant_text or terminal_text
|
|
error_detail = (
|
|
result.get("error")
|
|
or result.get("result")
|
|
or (result.get("errors") or [None])[0]
|
|
or response_text
|
|
or None
|
|
)
|
|
return {
|
|
"text": response_text,
|
|
"cost_usd": self.cost_usd,
|
|
"tokens_in": tokens_in,
|
|
"tokens_out": tokens_out,
|
|
"turns": self.turns,
|
|
"is_error": result.get("is_error", False),
|
|
"error": error_detail,
|
|
}
|
|
|
|
async def turn_stream(self, content: str, timeout: float = 600.0):
|
|
"""한 턴을 *증분 텍스트 청크*로 흘려보낸다 (SSE /v1/stream 용).
|
|
|
|
기존 turn() 은 result 까지 모아 한 번에 text 를 반환한다. 스트림 경로는
|
|
assistant 텍스트 델타가 도착하는 즉시 yield 해야 토큰 스트리밍 UX 가 산다.
|
|
마지막에 {"__done__": ...} 메타(누적 cost/turns)를 한 번 yield 한다.
|
|
|
|
주의: claude -p stream-json 의 assistant 메시지는 청크가 전체 누적본일 수도,
|
|
델타일 수도 있어 게이트웨이가 *이미 보낸 접두 길이*를 추적해 신규분만 흘린다.
|
|
"""
|
|
if self.proc is None or self.proc.returncode is not None:
|
|
raise RuntimeError("engine session not running")
|
|
async with self.lock:
|
|
msg = json.dumps(
|
|
{"type": "user", "message": {"role": "user", "content": content}},
|
|
ensure_ascii=False,
|
|
)
|
|
self.proc.stdin.write((msg + "\n").encode("utf-8"))
|
|
await self.proc.stdin.drain()
|
|
|
|
emitted = "" # 이미 흘려보낸 누적 텍스트 (델타 산출 기준)
|
|
|
|
async def _next_line():
|
|
return await asyncio.wait_for(self.proc.stdout.readline(), timeout=timeout)
|
|
|
|
while True:
|
|
line = await _next_line()
|
|
if not line:
|
|
break
|
|
try:
|
|
obj = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
t = obj.get("type")
|
|
if t == "stream_event":
|
|
stream_event = obj.get("event") or {}
|
|
delta_payload = stream_event.get("delta") or {}
|
|
if (
|
|
stream_event.get("type") == "content_block_delta"
|
|
and delta_payload.get("type") == "text_delta"
|
|
):
|
|
delta = str(delta_payload.get("text") or "")
|
|
if delta:
|
|
emitted += delta
|
|
yield {"type": "delta", "text": delta}
|
|
elif t == "assistant":
|
|
# 이번 메시지의 텍스트 전체를 재구성
|
|
full = "".join(
|
|
c.get("text", "")
|
|
for c in obj.get("message", {}).get("content", [])
|
|
if c.get("type") == "text"
|
|
)
|
|
if full.startswith(emitted):
|
|
delta = full[len(emitted):]
|
|
else:
|
|
# 누적이 아니라 메시지 분절 → 통째로 추가분
|
|
delta = full
|
|
if delta:
|
|
emitted += delta if full.startswith(emitted) else full
|
|
yield {"type": "delta", "text": delta}
|
|
elif t == "result":
|
|
self.last_used_at = time.monotonic()
|
|
self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
|
|
tokens_in, tokens_out = _claude_result_tokens(obj)
|
|
self.turns += 1
|
|
terminal_result = obj.get("result")
|
|
terminal_text = (
|
|
terminal_result if isinstance(terminal_result, str) else ""
|
|
)
|
|
if not emitted and terminal_text:
|
|
emitted = terminal_text
|
|
yield {"type": "delta", "text": terminal_text}
|
|
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,
|
|
"tokens_in": tokens_in,
|
|
"tokens_out": tokens_out,
|
|
"turns": self.turns,
|
|
"is_error": obj.get("is_error", False),
|
|
"error": error_detail,
|
|
}
|
|
return
|
|
|
|
async def close(self) -> None:
|
|
if self.proc and self.proc.returncode is None:
|
|
try:
|
|
self.proc.stdin.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(self.proc.wait(), timeout=5)
|
|
except Exception:
|
|
self.proc.kill()
|
|
task = self._stderr_task
|
|
self._stderr_task = None
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
|
|
|
|
SESSIONS: dict[str, EngineSession] = {}
|
|
_SESSION_RESOLVE_LOCK = asyncio.Lock()
|
|
_READY_CACHE: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
_READY_LOCK = asyncio.Lock()
|
|
app = FastAPI(title="Vignette Engine Gateway")
|
|
_AUTH_EXEMPT_PATHS = frozenset({"/health"})
|
|
|
|
|
|
@app.middleware("http")
|
|
async def require_gateway_shared_secret(request: Request, call_next):
|
|
"""설정된 인스턴스는 순수 liveness 외 모든 HTTP 경로를 fail-closed로 막는다."""
|
|
expected = ENGINE_GATEWAY_SHARED_SECRET
|
|
if expected and request.url.path not in _AUTH_EXEMPT_PATHS:
|
|
provided = request.headers.get(ENGINE_TOKEN_HEADER, "")
|
|
if not secrets.compare_digest(provided, expected):
|
|
return JSONResponse(
|
|
{"detail": "invalid engine gateway credentials"},
|
|
status_code=401,
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
class CreateReq(BaseModel):
|
|
system_prompt: str | None = None
|
|
budget_usd: float | None = None
|
|
|
|
|
|
class TurnReq(BaseModel):
|
|
content: str
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {
|
|
"ok": True,
|
|
"engine": "claude_cli",
|
|
"model": DEFAULT_MODEL or "gateway-default",
|
|
"sessions": len(SESSIONS),
|
|
}
|
|
|
|
|
|
def _ready_entry_ttl(entry: dict[str, Any]) -> float:
|
|
"""실패 항목은 짧은 TTL로만 캐시해 복구가 프로브 한 번 안에 감지되게 한다."""
|
|
if entry.get("ok"):
|
|
return READY_TTL_SECONDS
|
|
return min(READY_TTL_SECONDS, READY_FAILURE_TTL_SECONDS)
|
|
|
|
|
|
def _ready_response(
|
|
entry: dict[str, Any],
|
|
*,
|
|
provider: EngineProvider,
|
|
model: str | None,
|
|
reasoning_effort: ReasoningEffort | None,
|
|
cached: bool,
|
|
age_seconds: float = 0.0,
|
|
) -> JSONResponse:
|
|
ok = bool(entry.get("ok"))
|
|
return JSONResponse(
|
|
{
|
|
"ok": ok,
|
|
"engine": provider,
|
|
"model": model or (
|
|
DEFAULT_MODEL or "default(opus-4-8)"
|
|
if provider == "claude_cli"
|
|
else "provider-default"
|
|
),
|
|
"reasoning_effort": reasoning_effort,
|
|
"sessions": len(SESSIONS),
|
|
"detail": entry.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,
|
|
provider: EngineProvider = "claude_cli",
|
|
model: str | None = None,
|
|
reasoning_effort: ReasoningEffort | None = None,
|
|
):
|
|
"""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.
|
|
"""
|
|
cache_key = (provider, model or "", reasoning_effort or "")
|
|
entry = _READY_CACHE.get(
|
|
cache_key, {"checked_at": 0.0, "ok": False, "detail": "not checked"}
|
|
)
|
|
age = time.monotonic() - float(entry.get("checked_at", 0.0) or 0.0)
|
|
if not force and age < _ready_entry_ttl(entry):
|
|
return _ready_response(
|
|
entry,
|
|
provider=provider,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
cached=True,
|
|
age_seconds=age,
|
|
)
|
|
|
|
async with _READY_LOCK:
|
|
entry = _READY_CACHE.get(
|
|
cache_key, {"checked_at": 0.0, "ok": False, "detail": "not checked"}
|
|
)
|
|
age = time.monotonic() - float(entry.get("checked_at", 0.0) or 0.0)
|
|
if not force and age < _ready_entry_ttl(entry):
|
|
return _ready_response(
|
|
entry,
|
|
provider=provider,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
cached=True,
|
|
age_seconds=age,
|
|
)
|
|
|
|
ok = False
|
|
detail = "unknown readiness failure"
|
|
if provider == "claude_cli":
|
|
probe = EngineSession(
|
|
system_prompt="You are a readiness probe. Reply with exactly OK.",
|
|
budget=READY_BUDGET_USD,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
)
|
|
try:
|
|
await probe.start()
|
|
result = await probe.turn(
|
|
"Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS
|
|
)
|
|
if result.get("is_error"):
|
|
detail = await probe.failure_detail(
|
|
str(result.get("error") or "engine returned an error")
|
|
)
|
|
else:
|
|
text = str(result.get("text") or "").strip()
|
|
ok = bool(text)
|
|
detail = text or await probe.failure_detail(
|
|
"empty engine response"
|
|
)
|
|
except Exception as exc:
|
|
# TimeoutError 등 str()이 비는 예외가 있어 타입명을 최소 보장한다.
|
|
reason = str(exc).strip() or type(exc).__name__
|
|
detail = await probe.failure_detail(reason)
|
|
finally:
|
|
await probe.close()
|
|
else:
|
|
request = GwGenerateReq(
|
|
provider=provider,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
max_tokens=16,
|
|
temperature=0,
|
|
messages=[GwMessage(role="user", content="Reply with exactly OK.")],
|
|
)
|
|
try:
|
|
result = await generate_with_provider(
|
|
request,
|
|
system_prompt="You are a readiness probe. Reply with exactly OK.",
|
|
user_payload="Reply with exactly OK.",
|
|
)
|
|
ok = bool(result.text.strip())
|
|
detail = result.text.strip() or "empty engine response"
|
|
except Exception as exc:
|
|
detail = str(exc)
|
|
|
|
entry = {"checked_at": time.monotonic(), "ok": ok, "detail": detail}
|
|
_READY_CACHE[cache_key] = entry
|
|
return _ready_response(
|
|
entry,
|
|
provider=provider,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
cached=False,
|
|
)
|
|
|
|
|
|
@app.get("/v1/capabilities", response_model=EngineCapabilitiesResponse)
|
|
async def v1_capabilities(provider: EngineProvider, force: bool = False):
|
|
return await discover_capabilities(provider, force=force)
|
|
|
|
|
|
@app.post("/session")
|
|
async def create_session(req: CreateReq):
|
|
s = EngineSession(system_prompt=req.system_prompt, budget=req.budget_usd or DEFAULT_BUDGET)
|
|
await s.start()
|
|
SESSIONS[s.id] = s
|
|
return {"session_id": s.id}
|
|
|
|
|
|
@app.post("/session/{sid}/turn")
|
|
async def turn(sid: str, req: TurnReq):
|
|
s = SESSIONS.get(sid)
|
|
if not s:
|
|
raise HTTPException(404, "session not found")
|
|
return await s.turn(req.content)
|
|
|
|
|
|
@app.delete("/session/{sid}")
|
|
async def close_session(sid: str):
|
|
s = SESSIONS.pop(sid, None)
|
|
if s:
|
|
await s.close()
|
|
return {"closed": bool(s)}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# stateless 어댑터 — apps/api/app/engine_client.py 의 계약과 정합
|
|
# POST /v1/generate : EngineMessage[] → 1턴 생성 → GenerateResponse
|
|
# POST /v1/stream : 같은 입력 → SSE(text/event-stream) 토큰 스트림
|
|
# 상주 풀(/session 계열)은 그대로 유지하고, 단발/스트림은 아래가 흡수한다.
|
|
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) -> GatewayPromptParts:
|
|
"""EngineMessage[] → named prompt parts for the current gateway turn."""
|
|
system_parts: list[str] = []
|
|
turn_control_parts: list[str] = []
|
|
non_system: list[GwMessage] = []
|
|
for m in messages:
|
|
if m.role == "system":
|
|
if ai_role == "client" and not m.cache:
|
|
turn_control_parts.append(m.content)
|
|
else:
|
|
system_parts.append(m.content)
|
|
else:
|
|
non_system.append(m)
|
|
|
|
last_user_index: int | None = None
|
|
for index, m in enumerate(non_system):
|
|
if m.role == "user":
|
|
last_user_index = index
|
|
|
|
last_user = ""
|
|
if last_user_index is not None:
|
|
last_user = non_system[last_user_index].content
|
|
|
|
user_payload = last_user
|
|
current_user_payload = last_user
|
|
if ai_role == "client" and last_user_index is not None:
|
|
control = "\n\n".join(p for p in turn_control_parts if p.strip())
|
|
current_sections: list[str] = []
|
|
if control:
|
|
current_sections.append("[현재 턴 상태와 연기 지시]\n" + control)
|
|
current_sections.append("[이번 상담자 발화]\n" + last_user)
|
|
current_user_payload = "\n\n".join(current_sections)
|
|
|
|
history_parts: list[str] = []
|
|
for m in non_system[:last_user_index]:
|
|
content = m.content.strip()
|
|
if not content:
|
|
continue
|
|
speaker = "상담자" if m.role == "user" else "내담자"
|
|
history_parts.append(f"{speaker}: {content}")
|
|
history_sections = list(current_sections[:-1])
|
|
if history_parts:
|
|
history_sections.append("[직전 대화]\n" + "\n".join(history_parts))
|
|
history_sections.append(current_sections[-1])
|
|
user_payload = "\n\n".join(history_sections)
|
|
|
|
system_prompt = "\n\n".join(p for p in system_parts if p.strip())
|
|
return GatewayPromptParts(
|
|
system_prompt=system_prompt,
|
|
user_payload=user_payload,
|
|
current_user_payload=current_user_payload,
|
|
)
|
|
|
|
|
|
def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str:
|
|
"""structured_schema 가 오면 system 프롬프트에 JSON 스키마 준수 지시를 주입.
|
|
|
|
게이트웨이 단 strict 강제는 후속(claude -p 는 response_format 미지원). 현실적 차선.
|
|
"""
|
|
if not schema:
|
|
return system_prompt
|
|
directive = (
|
|
"\n\n[출력 형식 — 반드시 준수]\n"
|
|
"아래 JSON 스키마에 *정확히* 맞는 단일 JSON 객체만 출력한다. "
|
|
"설명·마크다운 코드펜스 없이 JSON 본문만.\n"
|
|
f"{json.dumps(schema, ensure_ascii=False)}"
|
|
)
|
|
return (system_prompt + directive) if system_prompt else directive.lstrip()
|
|
|
|
|
|
def _response_model_name(session: EngineSession) -> str:
|
|
return session.model or DEFAULT_MODEL or GATEWAY_FALLBACK_MODEL_NAME
|
|
|
|
|
|
async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[EngineSession, bool]:
|
|
"""내담자 회기는 session_id 에 바인딩하고, 나머지는 단발 세션으로 실행한다.
|
|
|
|
반환: (session, ephemeral). ephemeral=True 면 호출부가 응답 후 close 한다.
|
|
"""
|
|
requested_model = normalize_engine_gateway_model(req.model)
|
|
persistent_key = req.session_id if req.session_id and req.ai_role == "client" else None
|
|
if persistent_key:
|
|
async with _SESSION_RESOLVE_LOCK:
|
|
await _prune_resident_sessions(exclude={persistent_key})
|
|
existing = SESSIONS.get(persistent_key)
|
|
if existing is not None:
|
|
running = existing.proc is not None and existing.proc.returncode is None
|
|
same_model = requested_model is None or (existing.model or DEFAULT_MODEL) == requested_model
|
|
same_effort = req.reasoning_effort is None or existing.reasoning_effort == req.reasoning_effort
|
|
if running and same_model and same_effort:
|
|
existing.last_used_at = time.monotonic()
|
|
return existing, False
|
|
SESSIONS.pop(persistent_key, None)
|
|
await existing.close()
|
|
|
|
session = EngineSession(
|
|
system_prompt=system_prompt or None,
|
|
budget=DEFAULT_BUDGET,
|
|
model=requested_model,
|
|
reasoning_effort=req.reasoning_effort,
|
|
)
|
|
await session.start()
|
|
SESSIONS[persistent_key] = session
|
|
return session, False
|
|
|
|
# 평가·관리자 생성처럼 페르소나 회기와 정체성을 섞으면 안 되는 호출은 1회성 세션이다.
|
|
s = EngineSession(
|
|
system_prompt=system_prompt or None,
|
|
budget=DEFAULT_BUDGET,
|
|
model=requested_model,
|
|
reasoning_effort=req.reasoning_effort,
|
|
)
|
|
await s.start()
|
|
return s, True
|
|
|
|
|
|
async def _prune_resident_sessions(*, exclude: set[str] | None = None) -> None:
|
|
"""죽었거나 오래 유휴인 회기와 상한 초과 회기를 안전하게 정리한다."""
|
|
protected = exclude or set()
|
|
now = time.monotonic()
|
|
stale_keys = [
|
|
key
|
|
for key, session in SESSIONS.items()
|
|
if key not in protected
|
|
and not session.lock.locked()
|
|
and (
|
|
session.proc is None
|
|
or session.proc.returncode is not None
|
|
or now - session.last_used_at >= SESSION_IDLE_TTL_SECONDS
|
|
)
|
|
]
|
|
for key in stale_keys:
|
|
session = SESSIONS.pop(key, None)
|
|
if session is not None:
|
|
await session.close()
|
|
|
|
overflow = len(SESSIONS) - MAX_RESIDENT_SESSIONS + 1
|
|
if overflow <= 0:
|
|
return
|
|
candidates = sorted(
|
|
(
|
|
(key, session)
|
|
for key, session in SESSIONS.items()
|
|
if key not in protected and not session.lock.locked()
|
|
),
|
|
key=lambda item: item[1].last_used_at,
|
|
)
|
|
for key, session in candidates[:overflow]:
|
|
SESSIONS.pop(key, None)
|
|
await session.close()
|
|
|
|
|
|
def _session_turn_payload(session: EngineSession, prompt_parts: GatewayPromptParts) -> str:
|
|
"""상주 프로세스는 자체 대화기록을 가지므로 재사용 턴에는 L6를 중복 주입하지 않는다."""
|
|
if session.turns > 0:
|
|
return prompt_parts.current_user_payload
|
|
return prompt_parts.user_payload
|
|
|
|
|
|
@app.post("/v1/generate")
|
|
async def v1_generate(req: GwGenerateReq):
|
|
"""단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환."""
|
|
prompt_parts = _split_messages(req.messages, ai_role=req.ai_role)
|
|
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
|
|
if not prompt_parts.user_payload:
|
|
raise HTTPException(400, "no user message in payload")
|
|
|
|
provider = req.provider or "claude_cli"
|
|
if provider != "claude_cli":
|
|
try:
|
|
result = await generate_with_provider(
|
|
req,
|
|
system_prompt=system_prompt,
|
|
user_payload=prompt_parts.user_payload,
|
|
)
|
|
except ProviderError as exc:
|
|
raise HTTPException(502, f"engine provider error: {exc}") from exc
|
|
return GenerateResponse(
|
|
text=result.text,
|
|
model=result.model,
|
|
provider=result.provider,
|
|
tokens_in=result.tokens_in,
|
|
tokens_out=result.tokens_out,
|
|
cost_usd=result.cost_usd,
|
|
inference_geo=result.inference_geo,
|
|
structured=result.structured,
|
|
).model_dump()
|
|
|
|
s, ephemeral = await _resolve_session(req, system_prompt)
|
|
try:
|
|
result = await s.turn(
|
|
_session_turn_payload(s, prompt_parts),
|
|
timeout=GENERATE_TURN_TIMEOUT_SECONDS,
|
|
)
|
|
finally:
|
|
if ephemeral:
|
|
await s.close()
|
|
if result.get("is_error"):
|
|
raise HTTPException(502, f"engine turn error: {result.get('error')}")
|
|
|
|
structured: Optional[dict] = None
|
|
text = result.get("text", "")
|
|
if req.structured_schema:
|
|
try:
|
|
structured = json.loads(text)
|
|
except (json.JSONDecodeError, TypeError):
|
|
structured = None # 파싱 실패는 호출부가 text 로 폴백
|
|
return GenerateResponse(
|
|
text=text,
|
|
model=_response_model_name(s),
|
|
provider=GATEWAY_PROVIDER,
|
|
tokens_in=result.get("tokens_in", 0),
|
|
tokens_out=result.get("tokens_out", 0),
|
|
cost_usd=result.get("cost_usd", 0.0),
|
|
inference_geo="us",
|
|
structured=structured,
|
|
).model_dump()
|
|
|
|
|
|
@app.post("/v1/stream")
|
|
async def v1_stream(req: GwGenerateReq):
|
|
"""SSE 토큰 스트림. data: 라인으로 텍스트 델타를 흘리고 done/error 프레이밍."""
|
|
prompt_parts = _split_messages(req.messages, ai_role=req.ai_role)
|
|
system_prompt = _inject_schema(prompt_parts.system_prompt, req.structured_schema)
|
|
if not prompt_parts.user_payload:
|
|
raise HTTPException(400, "no user message in payload")
|
|
|
|
provider = req.provider or "claude_cli"
|
|
if provider != "claude_cli":
|
|
async def _provider_sse():
|
|
try:
|
|
async for event in stream_with_provider(
|
|
req,
|
|
system_prompt=system_prompt,
|
|
user_payload=prompt_parts.user_payload,
|
|
):
|
|
if event.type == "delta" and event.text:
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_TOKEN,
|
|
StreamTokenEvent(text=event.text),
|
|
)
|
|
elif event.type == "done" and event.result is not None:
|
|
result = event.result
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_DONE,
|
|
StreamDoneEvent(
|
|
provider=result.provider,
|
|
model=result.model,
|
|
tokens_in=result.tokens_in,
|
|
tokens_out=result.tokens_out,
|
|
cost_usd=result.cost_usd,
|
|
turns=1,
|
|
),
|
|
)
|
|
except ProviderError as exc:
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_ERROR,
|
|
StreamErrorEvent(detail=f"engine provider error: {exc}"),
|
|
)
|
|
|
|
return StreamingResponse(
|
|
_provider_sse(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
s, ephemeral = await _resolve_session(req, system_prompt)
|
|
|
|
async def _sse():
|
|
try:
|
|
async for evt in s.turn_stream(
|
|
_session_turn_payload(s, prompt_parts),
|
|
timeout=600.0,
|
|
):
|
|
if evt.get("type") == "delta":
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_TOKEN,
|
|
StreamTokenEvent(text=evt["text"]),
|
|
)
|
|
elif evt.get("type") == "done":
|
|
if evt.get("is_error"):
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_ERROR,
|
|
StreamErrorEvent(detail=str(evt.get("error"))),
|
|
)
|
|
else:
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_DONE,
|
|
StreamDoneEvent(
|
|
provider=GATEWAY_PROVIDER,
|
|
model=_response_model_name(s),
|
|
tokens_in=evt.get("tokens_in", 0),
|
|
tokens_out=evt.get("tokens_out", 0),
|
|
cost_usd=evt.get("cost_usd", 0.0),
|
|
turns=evt.get("turns", 0),
|
|
),
|
|
)
|
|
except Exception as e: # 전송 도중 실패도 SSE 프레임으로 알린다
|
|
yield sse_frame(
|
|
ENGINE_GATEWAY_SSE_ERROR,
|
|
StreamErrorEvent(detail=str(e)),
|
|
)
|
|
finally:
|
|
if ephemeral:
|
|
await s.close()
|
|
|
|
return StreamingResponse(
|
|
_sse(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|