Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m0s
omniroute와 동일하게 공개 클라이언트 자격증명으로 서버사이드 OAuth 교환을 제공한다. 관리자가 제공자 로그인 후 브라우저에 남는 code를 붙여넣으면 토큰·refresh token·메타데이터(account-id, Code Assist project)를 저장하고 게이트웨이에 push한다. - codex_api 어댑터: chatgpt.com/backend-api/codex/responses (Responses SSE, 401 시 refresh token으로 자가 갱신) - antigravity_api 어댑터: cloudcode-pa v1internal:streamGenerateContent (Gemini 형식 SSE, loadCodeAssist로 프로젝트 발급, 401 자가 갱신) - 자격증명 저장소에 refresh_token_encrypted·extra 컬럼 추가(부트스트랩 SQL 포함), 게이트웨이 push가 구조화 자격증명을 전달
1041 lines
41 KiB
Python
1041 lines
41 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,
|
|
clear_capability_cache,
|
|
discover_capabilities,
|
|
generate_with_provider,
|
|
set_provider_credential,
|
|
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"})
|
|
|
|
# 게이트웨이는 DB가 없다. 관리자 API가 저장한 자격증명을 shared-secret 미들웨어 뒤
|
|
# 내부 엔드포인트로 push받아 이 프로세스 환경(os.environ)에 주입한다. boot_id는
|
|
# API가 재시작된 게이트웨이를 감지해 자격증명을 재-push하게 하는 무결성 표식이다.
|
|
GATEWAY_BOOT_ID = uuid.uuid4().hex
|
|
|
|
# provider 연결 코드(os.environ에 주입할 키). claude는 api_key와 OAuth 토큰이
|
|
# 환경 변수가 다르므로 둘 다 둔다.
|
|
_PROVIDER_CREDENTIAL_ENV_VARS: dict[str, tuple[str, ...]] = {
|
|
"claude": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
|
|
"codex": ("OPENAI_API_KEY",),
|
|
"agy": ("AGY_API_KEY",),
|
|
"openrouter": ("OPENROUTER_API_KEY",),
|
|
}
|
|
|
|
|
|
class ProviderCredentialEntry(BaseModel):
|
|
token: str
|
|
auth_kind: str = "api_key"
|
|
refresh_token: str = ""
|
|
extra: dict[str, Any] = {}
|
|
|
|
|
|
class ProviderCredentialsPush(BaseModel):
|
|
providers: dict[str, ProviderCredentialEntry]
|
|
|
|
|
|
def _credential_loaded(provider: str) -> bool:
|
|
for name in _PROVIDER_CREDENTIAL_ENV_VARS.get(provider, ()):
|
|
if os.environ.get(name, "").strip():
|
|
return True
|
|
return False
|
|
|
|
|
|
@app.get("/internal/provider-credentials")
|
|
async def internal_provider_credentials_status():
|
|
return {
|
|
"ok": True,
|
|
"boot_id": GATEWAY_BOOT_ID,
|
|
"loaded_providers": [
|
|
provider for provider in _PROVIDER_CREDENTIAL_ENV_VARS if _credential_loaded(provider)
|
|
],
|
|
}
|
|
|
|
|
|
@app.post("/internal/provider-credentials")
|
|
async def internal_push_provider_credentials(payload: ProviderCredentialsPush):
|
|
applied: list[str] = []
|
|
for provider, entry in payload.providers.items():
|
|
env_names = _PROVIDER_CREDENTIAL_ENV_VARS.get(provider)
|
|
if env_names is None:
|
|
continue
|
|
token = entry.token.strip()
|
|
if not token:
|
|
continue
|
|
if provider == "claude":
|
|
if entry.auth_kind == "oauth_token":
|
|
os.environ.pop("ANTHROPIC_API_KEY", None)
|
|
os.environ["ANTHROPIC_AUTH_TOKEN"] = token
|
|
else:
|
|
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
os.environ["ANTHROPIC_API_KEY"] = token
|
|
elif provider in {"codex", "agy"} and entry.auth_kind == "oauth_token":
|
|
# ChatGPT/Google OAuth 토큰은 OPENAI/AGY API 키로 쓸 수 없다.
|
|
# 구조화 자격증명으로만 보관하고 해당 어댑터가 읽는다.
|
|
pass
|
|
else:
|
|
os.environ[env_names[0]] = token
|
|
set_provider_credential(
|
|
provider,
|
|
token=token,
|
|
auth_kind=entry.auth_kind,
|
|
refresh_token=entry.refresh_token,
|
|
extra=entry.extra,
|
|
)
|
|
applied.append(provider)
|
|
clear_capability_cache()
|
|
return {
|
|
"ok": True,
|
|
"boot_id": GATEWAY_BOOT_ID,
|
|
"applied": applied,
|
|
}
|
|
|
|
|
|
@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"},
|
|
)
|