feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -346,7 +346,12 @@ async def _runtime_tables_ready(conn) -> bool:
to_regclass('app.persona_voice_map') IS NOT NULL AS has_persona_voice_map,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'admin_engine_config'
AND column_name = 'reasoning_effort'
) AS has_engine_config,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
@ -650,11 +655,18 @@ async def ensure_runtime_tables() -> None:
engine_mode TEXT NOT NULL,
engine_url TEXT NOT NULL,
model TEXT NOT NULL,
reasoning_effort TEXT,
updated_by TEXT,
updated_at TIMESTAMPTZ
)
"""
)
await conn.execute(
"""
ALTER TABLE app.admin_engine_config
ADD COLUMN IF NOT EXISTS reasoning_effort TEXT
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.admin_health_event (
@ -1226,6 +1238,15 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
normalized_email = _normalize_email(data.email)
normalized_external_id = _normalize_external_id(data.external_id, normalized_email)
manual_external_id = f"email:{normalized_email}"
desired_admin_access = (
data.admin_access
if data.admin_access is not None
else (
True
if has_admin_access(normalized_email, data.role, False)
else None
)
)
desired_account_status = data.account_status or _initial_account_status(
email=normalized_email,
external_id=normalized_external_id,
@ -1242,6 +1263,10 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
display_name = COALESCE(NULLIF(display_name, ''), $3),
cohort = COALESCE(cohort, $4),
affiliation = COALESCE(NULLIF(affiliation, ''), $5),
admin_access = CASE
WHEN $7::boolean IS NULL THEN admin_access
ELSE $7::boolean
END,
last_seen_at = now(),
updated_at = now()
WHERE user_id = (
@ -1293,6 +1318,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
_cohort_value(data.cohort_ids),
data.affiliation or DEFAULT_AFFILIATION,
manual_external_id,
desired_admin_access,
)
if row is not None:
user = _managed_user_from_row(row)
@ -1314,13 +1340,13 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
last_seen_at,
updated_at
)
VALUES ($1, $2, $3, $4, COALESCE($9, false), $5, $6, $8, now(), now())
VALUES ($1, $2, $3, $4, COALESCE($9::boolean, false), $5, $6, $8, now(), now())
ON CONFLICT (external_id) DO UPDATE SET
email = EXCLUDED.email,
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
role = EXCLUDED.role,
admin_access = CASE
WHEN $9 IS NULL THEN app.app_user.admin_access
WHEN $9::boolean IS NULL THEN app.app_user.admin_access
ELSE EXCLUDED.admin_access
END,
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
@ -1369,7 +1395,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
data.affiliation or DEFAULT_AFFILIATION,
data.reactivate,
desired_account_status,
data.admin_access,
desired_admin_access,
)
if row is None:
_inactive_emails.add(normalized_email)
@ -1382,6 +1408,8 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
except InactiveUserError:
raise
except Exception:
if not runtime_fallback_allowed():
logger.exception("managed user persistence failed")
require_runtime_fallback_allowed("managed user")
current = _users.get(data.user_id or "") or _users.get(
_email_index.get(normalized_email, "")
@ -1405,7 +1433,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
email=normalized_email,
display_name=data.display_name,
role=data.role,
admin_access=data.admin_access,
admin_access=desired_admin_access,
account_status=fallback_account_status,
cohort_ids=data.cohort_ids,
user_id=fallback_uid,
@ -2008,24 +2036,42 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
key,
)
if row is not None:
app_role = _app_role(row["role"])
stored_admin_access = bool(
_row_value(row, "admin_access", False)
)
effective_admin_access = has_admin_access(
row["email"],
app_role,
stored_admin_access,
)
await conn.execute(
"UPDATE app.auth_session SET last_seen_at = now() WHERE sid_hash = $1",
key,
)
await conn.execute(
"UPDATE app.app_user SET last_seen_at = now() WHERE user_id = $1",
"""
UPDATE app.app_user SET
last_seen_at = now(),
admin_access = CASE
WHEN $2 THEN TRUE
ELSE admin_access
END,
updated_at = CASE
WHEN $2 AND NOT admin_access THEN now()
ELSE updated_at
END
WHERE user_id = $1
""",
row["user_id"],
effective_admin_access,
)
return SessionUser(
user_id=str(row["user_id"]),
email=row["email"],
display_name=row["display_name"],
role=_app_role(row["role"]),
admin_access=has_admin_access(
row["email"],
_app_role(row["role"]),
bool(_row_value(row, "admin_access", False)),
),
role=app_role,
admin_access=effective_admin_access,
super_admin=is_super_admin_email(row["email"]),
account_status=_account_status(row["account_status"]),
cohort_ids=_cohort_ids(row["cohort"]),

View file

@ -13,12 +13,16 @@ from urllib.parse import urlsplit
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from .contracts.engine_gateway import EngineProvider
# 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피)
# claude_api = Anthropic Messages API 직결 (기본)
# claude_cli = 로컬 claude -p 상주풀 (stream-json, 옵션/시연용)
# codex_cli = 로컬 Codex CLI (app-server 모델 탐색 + exec 생성)
# agy_cli = 로컬 Agy/Google Antigravity CLI
# openai = OpenAI 호환 (폴백/평가 보조)
# solar = 국내 모델 라우팅 (PII 민감구간 inference_geo:kr)
EngineMode = Literal["claude_api", "claude_cli", "openai", "solar"]
EngineMode = EngineProvider
def _is_local_url(value: str) -> bool:
@ -78,6 +82,12 @@ class Settings(BaseSettings):
default="claude_api",
validation_alias="ENGINE_MODE",
)
# 실시간 내담자 응답은 평가/저작 provider와 분리할 수 있다. Agy/Codex 같은
# 범용 agent CLI의 도구 prompt와 프로세스 기동 지연을 상담 왕복에 강제하지 않는다.
live_client_provider: EngineMode | None = Field(
default=None,
validation_alias="VIGNETTE_LIVE_CLIENT_PROVIDER",
)
engine_timeout: float = 120.0 # SSE 롱리브드 (50분 상담 대비, 스트림은 무제한 별도)
engine_connect_timeout: float = 10.0
admin_usage_budget_usd: float = Field(
@ -136,6 +146,18 @@ class Settings(BaseSettings):
default="",
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR",
)
voice_tts_provider: Literal["openai", "higgs"] = Field(
default="openai",
validation_alias="VIGNETTE_VOICE_TTS_PROVIDER",
)
higgs_tts_url: str = Field(
default="http://127.0.0.1:9881",
validation_alias="VIGNETTE_HIGGS_TTS_URL",
)
higgs_tts_timeout_seconds: float = Field(
default=300.0,
validation_alias="VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS",
)
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
session_secret: str = Field(
@ -310,6 +332,9 @@ class Settings(BaseSettings):
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
if self.voice_poc_sample_tts_enabled:
forbidden.append("VIGNETTE_VOICE_POC_SAMPLE_TTS")
if self.voice_tts_provider == "higgs":
# Higgs Audio v3 TTS 4B는 연구/비상업 라이선스이므로 로컬 dev에서만 쓴다.
forbidden.append("VIGNETTE_VOICE_TTS_PROVIDER=higgs")
if not self.oauth_google_client_id.strip():
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
if not self.oauth_google_client_secret.strip():

View file

@ -15,6 +15,16 @@ from pydantic import BaseModel, Field
AIRole = Literal["client", "counselor", "evaluator"]
EngineMessageRole = Literal["system", "user", "assistant"]
EngineGatewaySseEvent = Literal["token", "done", "error"]
EngineProvider = Literal[
"claude_cli",
"claude_api",
"codex_cli",
"agy_cli",
"openai",
"solar",
]
ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max", "ultra"]
EngineCapabilitySource = Literal["live_cli", "live_api", "static_cli", "unavailable"]
ENGINE_GATEWAY_SSE_TOKEN: EngineGatewaySseEvent = "token"
ENGINE_GATEWAY_SSE_DONE: EngineGatewaySseEvent = "done"
@ -25,6 +35,30 @@ ENGINE_GATEWAY_SSE_EVENTS: tuple[EngineGatewaySseEvent, ...] = (
ENGINE_GATEWAY_SSE_ERROR,
)
ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL = "gateway-default"
ENGINE_PROVIDERS: tuple[EngineProvider, ...] = (
"claude_cli",
"claude_api",
"codex_cli",
"agy_cli",
"openai",
"solar",
)
ENGINE_REASONING_EFFORTS: tuple[ReasoningEffort, ...] = (
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
)
ENGINE_PROVIDER_DEFAULTS: dict[EngineProvider, tuple[str, Optional[ReasoningEffort]]] = {
"claude_cli": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
"claude_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
"codex_cli": ("gpt-5.6-terra", "medium"),
"agy_cli": ("gemini-3.6-flash-high", "high"),
"openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
}
def normalize_engine_gateway_model(model: Optional[str]) -> Optional[str]:
@ -45,7 +79,9 @@ class EngineMessage(BaseModel):
class GenerateRequest(BaseModel):
ai_role: AIRole = "client"
messages: list[EngineMessage]
provider: Optional[EngineProvider] = None
model: Optional[str] = None
reasoning_effort: Optional[ReasoningEffort] = None
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None
@ -68,6 +104,26 @@ class GenerateResponse(BaseModel):
structured: Optional[dict[str, Any]] = None
class EngineModelOption(BaseModel):
id: str
label: str
description: str = ""
reasoning_efforts: list[ReasoningEffort] = Field(default_factory=list)
default_reasoning_effort: Optional[ReasoningEffort] = None
is_default: bool = False
class EngineCapabilitiesResponse(BaseModel):
provider: EngineProvider
available: bool
source: EngineCapabilitySource
models: list[EngineModelOption] = Field(default_factory=list)
default_model: Optional[str] = None
default_reasoning_effort: Optional[ReasoningEffort] = None
detail: str = ""
fetched_at: float
def structured_payload_from_response(resp: GenerateResponse) -> dict[str, Any] | None:
"""Return structured output, or a JSON object embedded in legacy text."""

View file

@ -148,7 +148,12 @@ async def healthcheck() -> bool:
to_regclass('app.app_user') IS NOT NULL AS has_user,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'admin_engine_config'
AND column_name = 'reasoning_effort'
) AS has_engine_config,
to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event,
to_regclass('app.admin_health_daily_rollup') IS NOT NULL AS has_admin_health_daily_rollup,
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket,

View file

@ -1,12 +1,14 @@
"""엔진 게이트웨이 HTTP 클라이언트.
게이트웨이 자체(apps/api/engine_gateway/) 람다가 직접 만든다. 여기는 *호출부*.
게이트웨이가 provider 라우팅(claude_api/claude_cli/openai/solar)·캐싱·상주 claude -p 풀을
흡수한다(마스터플랜 §0, R1). 백엔드는 ENGINE_URL HTTP 호출만 한다.
게이트웨이가 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) 게이트웨이에 넘기되,
@ -23,11 +25,14 @@ 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,
)
@ -43,7 +48,9 @@ class EngineClient:
def __init__(self, base_url: Optional[str] = None) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/")
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()
@ -71,8 +78,9 @@ class EngineClient:
self,
*,
base_url: str,
engine_mode: 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)
@ -81,6 +89,7 @@ class EngineClient:
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()
@ -88,8 +97,22 @@ class EngineClient:
def _payload(self, req: GenerateRequest) -> dict[str, Any]:
payload = req.model_dump(exclude_none=True)
if self.default_model and "model" not in payload:
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
@ -103,7 +126,12 @@ class EngineClient:
async def health_detail(self) -> dict[str, Any]:
try:
r = await self.client.get("/ready")
params: dict[str, str] = {"provider": self.engine_mode}
if self.default_model:
params["model"] = self.default_model
if self.default_reasoning_effort:
params["reasoning_effort"] = self.default_reasoning_effort
r = await self.client.get("/ready", params=params)
if r.status_code == 404:
live = await self.client.get("/health")
return {
@ -129,6 +157,33 @@ class EngineClient:
"status_code": None,
}
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,
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) -> GenerateResponse:
"""단발 생성."""
try:
@ -172,6 +227,18 @@ class EngineClient:
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
# 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
engine_client = EngineClient()

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import time
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Literal
from typing import Annotated, Literal, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
@ -25,7 +25,14 @@ from ..auth_sessions import (
upsert_managed_user,
)
from ..config import settings
from ..contracts.engine_gateway import ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL
from ..contracts.engine_gateway import (
ENGINE_PROVIDER_DEFAULTS,
ENGINE_PROVIDERS,
ENGINE_REASONING_EFFORTS,
EngineCapabilitiesResponse,
EngineProvider,
ReasoningEffort,
)
from ..db import acquire, get_pool, healthcheck
from ..deps import Principal, require_admin_access
from ..engine_client import engine_client
@ -269,9 +276,10 @@ class AdminTicketsResponse(BaseModel):
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_mode: EngineProvider
engine_url: str
model: str
reasoning_effort: ReasoningEffort | None = None
updated_by: str | None = None
updated_at: float | None = None
durable: bool = False
@ -282,6 +290,7 @@ class AdminEngineConfigPatch(BaseModel):
engine_mode: str | None = None
engine_url: str | None = None
model: str | None = None
reasoning_effort: str | None = None
class AdminTicketPatch(BaseModel):
@ -977,7 +986,7 @@ class AdminUserDeleteResponse(BaseModel):
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
ENGINE_MODES = set(ENGINE_PROVIDERS)
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
@ -992,23 +1001,37 @@ def _normalize_email(value: str) -> str:
def _default_engine_config() -> AdminEngineConfigResponse:
default_model, default_effort = ENGINE_PROVIDER_DEFAULTS[settings.engine_mode]
return AdminEngineConfigResponse(
engine_mode=settings.engine_mode,
engine_url=settings.engine_url,
model=ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
model=default_model,
reasoning_effort=default_effort,
durable=False,
source="runtime_default",
)
def _normalize_engine_mode(value: str) -> str:
def _normalize_engine_mode(value: str) -> EngineProvider:
mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip())
if mode not in ENGINE_MODES:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported engine mode {value}",
)
return mode
return cast(EngineProvider, mode)
def _normalize_reasoning_effort(value: str | None) -> ReasoningEffort | None:
effort = (value or "").strip().lower()
if not effort:
return None
if effort not in ENGINE_REASONING_EFFORTS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported reasoning effort {value}",
)
return cast(ReasoningEffort, effort)
def _normalize_engine_url(value: str) -> str:
@ -1071,6 +1094,7 @@ def _engine_config_from_row(row) -> AdminEngineConfigResponse:
engine_mode=_normalize_engine_mode(row["engine_mode"]),
engine_url=_normalize_engine_url(row["engine_url"]),
model=row["model"],
reasoning_effort=_normalize_reasoning_effort(row.get("reasoning_effort")),
updated_by=row["updated_by"],
updated_at=_updated_at_ts(row["updated_at"]),
durable=True,
@ -1266,7 +1290,7 @@ async def _current_engine_config() -> AdminEngineConfigResponse:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT engine_mode, engine_url, model, updated_by, updated_at
SELECT engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
FROM app.admin_engine_config
WHERE id = TRUE
"""
@ -1286,6 +1310,7 @@ async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
base_url=config.engine_url,
engine_mode=config.engine_mode,
default_model=config.model,
default_reasoning_effort=config.reasoning_effort,
)
return config
@ -1752,6 +1777,74 @@ async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigRespo
return await _current_engine_config()
@router.get("/engine-capabilities", response_model=EngineCapabilitiesResponse)
async def get_engine_capabilities(
principal: AdminPrincipal,
engine_mode: str | None = Query(default=None),
engine_url: str | None = Query(default=None),
force: bool = Query(default=False),
) -> EngineCapabilitiesResponse:
"""Return gateway-discovered models and reasoning levels for one provider."""
current = await _current_engine_config()
provider = _normalize_engine_mode(engine_mode or current.engine_mode)
capability_url = (
_normalize_engine_url(engine_url)
if engine_url is not None
else current.engine_url
)
try:
return await engine_client.capabilities(
provider=provider,
base_url=capability_url,
force=force,
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"engine capabilities unavailable: {exc}",
) from exc
async def _validate_engine_selection(
*,
provider: EngineProvider,
engine_url: str,
model: str,
reasoning_effort: ReasoningEffort | None,
) -> None:
try:
capabilities = await engine_client.capabilities(
provider=provider,
base_url=engine_url,
force=True,
)
except Exception as exc:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"선택한 엔진의 모델 목록을 검증할 수 없습니다: {exc}",
) from exc
if not capabilities.available:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=capabilities.detail or "선택한 엔진을 사용할 수 없습니다.",
)
selected = next((option for option in capabilities.models if option.id == model), None)
if selected is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{provider}에서 사용할 수 없는 모델입니다: {model}",
)
if (
reasoning_effort is not None
and reasoning_effort not in selected.reasoning_efforts
):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{model}에서 사용할 수 없는 추론 강도입니다: {reasoning_effort}",
)
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
async def patch_engine_config(
body: AdminEngineConfigPatch,
@ -1763,10 +1856,28 @@ async def patch_engine_config(
current = await _current_engine_config()
next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode)
next_url = _normalize_engine_url(body.engine_url or current.engine_url)
next_model = (body.model or current.model).strip()
if not next_model:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="model is required",
)
next_effort = _normalize_reasoning_effort(
body.reasoning_effort
if "reasoning_effort" in body.model_fields_set
else current.reasoning_effort
)
await _validate_engine_selection(
provider=next_mode,
engine_url=next_url,
model=next_model,
reasoning_effort=next_effort,
)
next_config = AdminEngineConfigResponse(
engine_mode=next_mode,
engine_url=next_url,
model=(body.model or current.model).strip(),
model=next_model,
reasoning_effort=next_effort,
updated_by=principal.email,
updated_at=time.time(),
durable=False,
@ -1778,20 +1889,22 @@ async def patch_engine_config(
row = await conn.fetchrow(
"""
INSERT INTO app.admin_engine_config (
id, engine_mode, engine_url, model, updated_by, updated_at
id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
)
VALUES (TRUE, $1, $2, $3, $4, now())
VALUES (TRUE, $1, $2, $3, $4, $5, now())
ON CONFLICT (id) DO UPDATE SET
engine_mode = EXCLUDED.engine_mode,
engine_url = EXCLUDED.engine_url,
model = EXCLUDED.model,
reasoning_effort = EXCLUDED.reasoning_effort,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING engine_mode, engine_url, model, updated_by, updated_at
RETURNING engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
""",
next_config.engine_mode,
next_config.engine_url,
next_config.model,
next_config.reasoning_effort,
principal.email,
)
next_config = _engine_config_from_row(row)
@ -1806,6 +1919,7 @@ async def patch_engine_config(
base_url=next_config.engine_url,
engine_mode=next_config.engine_mode,
default_model=next_config.model,
default_reasoning_effort=next_config.reasoning_effort,
)
return next_config

View file

@ -71,12 +71,13 @@ from ..session_read_model import (
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, store
from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
_SESSION_EVALUATION_IN_FLIGHT: set[str] = set()
_SESSION_EVALUATION_RECOVERY_TASK: asyncio.Task[int] | None = None
_STREAM_TURN_EVALUATION_TASKS: set[asyncio.Task[None]] = set()
TheoryMode = Literal["humanistic", "cbt", "integrative"]
EndStateValue = str | int | float | bool | None | dict[str, float]
@ -670,9 +671,11 @@ async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -
if _should_schedule_session_digest_worker(carry):
asyncio.create_task(_run_session_digest_worker_for_session(sess.session_id))
asyncio.create_task(_write_episodic_embeddings(sess))
asyncio.create_task(engine_client.close_session(sess.session_id))
return
require_runtime_fallback_allowed("session end")
store.end(sess.session_id)
asyncio.create_task(engine_client.close_session(sess.session_id))
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
@ -770,6 +773,84 @@ async def _evaluate_stream_turn(
return orchestrator.turn_evaluation_error_payload(ctx, exc)
async def _evaluate_and_persist_stream_turn(
*,
sess: InProcSession,
ctx: orchestrator.TurnContext,
final_reply: str,
result: orchestrator.TurnResult,
learner_turn: TurnRecord,
) -> None:
"""응답 완료 뒤 fast-loop 평가를 저장해 다음 발화의 임계 경로에서 분리한다."""
evaluation = await _evaluate_stream_turn(ctx, final_reply)
if evaluation is None:
return
if learner_turn.turn_id is not None:
saved = await session_persistence.replace_turn_evaluation(
turn_id=learner_turn.turn_id,
evaluation=evaluation,
)
if not saved:
logger.warning(
"turn fast-loop evaluation was not saved: session_id=%s turn_id=%s",
ctx.session_id,
learner_turn.turn_id,
)
return
learner_turn.evaluation = evaluation
cached = store.get(ctx.session_id)
if cached is not None:
for turn in cached.turns:
if learner_turn.turn_id and turn.turn_id == learner_turn.turn_id:
turn.evaluation = evaluation
break
if (
learner_turn.turn_id is None
and turn.speaker == "counselor"
and turn.turn_seq == learner_turn.turn_seq
):
turn.evaluation = evaluation
break
result.evaluation = evaluation
await turn_runtime.maybe_recharge_live_coach_credit(sess, ctx, result)
def _observe_stream_turn_evaluation_task(task: asyncio.Task[None]) -> None:
_STREAM_TURN_EVALUATION_TASKS.discard(task)
try:
task.result()
except asyncio.CancelledError:
logger.info("turn fast-loop evaluation background task cancelled")
except Exception:
logger.exception("turn fast-loop evaluation background task crashed")
def _schedule_stream_turn_evaluation(
*,
sess: InProcSession,
ctx: orchestrator.TurnContext,
final_reply: str,
result: orchestrator.TurnResult,
learner_turn: TurnRecord,
) -> asyncio.Task[None]:
task = asyncio.create_task(
_evaluate_and_persist_stream_turn(
sess=sess,
ctx=ctx,
final_reply=final_reply,
result=result,
learner_turn=learner_turn,
),
name=f"turn-evaluation:{ctx.session_id}:{result.turn_seq}",
)
_STREAM_TURN_EVALUATION_TASKS.add(task)
task.add_done_callback(_observe_stream_turn_evaluation_task)
return task
def _stream_result_from_done(
ctx: orchestrator.TurnContext,
final_reply: str,
@ -1649,15 +1730,22 @@ async def stream_turn(
).model_dump(),
}
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(
ctx, final_reply, data, evaluation
ctx, final_reply, data, None
)
await turn_runtime.finalize_completed_turn(
learner_turn = await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
recharge_live_coach=False,
)
_schedule_stream_turn_evaluation(
sess=sess,
ctx=ctx,
final_reply=final_reply,
result=result,
learner_turn=learner_turn,
)
finalized_turn = True
yield {

View file

@ -158,14 +158,26 @@ _PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
async def voice_health() -> JSONResponse:
"""Return voice service readiness."""
available = voice_service.is_available()
stt_available = voice_service.stt_available()
tts_available = voice_service.tts_available()
tts_provider = voice_service.tts_provider()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_available": stt_available,
"tts_available": tts_available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"tts_model": (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
),
"tts_provider": tts_provider,
"reason": None if available else "OPENAI_API_KEY is not configured",
"reason": (
None
if available
else "STT 또는 TTS provider가 준비되지 않았습니다"
),
}
return JSONResponse(body, status_code=200 if available else 503)
@ -192,12 +204,6 @@ async def voice_speech(
access_error = await _practice_access_error(learner)
if access_error is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error)
if not voice_service.is_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OPENAI_API_KEY is not configured",
)
sess, err = await turn_runtime.load_owned_session(
body.session_id,
learner,
@ -224,6 +230,11 @@ async def voice_speech(
persona_code=sess.persona.code,
explicit_preset=None,
)
if not voice_service.tts_available(voice_preset):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="TTS provider is not configured",
)
try:
chunks = [
chunk.audio
@ -248,11 +259,11 @@ async def voice_speech(
)
return Response(
content=audio,
media_type="audio/mpeg",
media_type=voice_service.tts_media_type_for_voice(voice_preset),
headers={
"Cache-Control": "no-store",
"X-Vignette-TTS-Model": voice_svc.TTS_MODEL,
"X-Vignette-TTS-Provider": voice_service.tts_provider(),
"X-Vignette-TTS-Model": voice_service.tts_model_for_voice(voice_preset),
"X-Vignette-TTS-Provider": voice_service.tts_provider_for_voice(voice_preset),
},
)
@ -314,7 +325,7 @@ async def voice_ws(websocket: WebSocket) -> None:
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"tts_provider": voice_service.tts_provider(),
"tts_provider": voice_service.tts_provider_for_voice(voice_preset),
"state": "idle",
**bind_meta,
},
@ -751,7 +762,7 @@ async def _run_turn_and_speak(
"type": "state",
"state": "speaking",
"voice": context.voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
"tts_provider": voice_service.tts_provider_for_voice(context.voice_preset),
},
)
try:
@ -787,15 +798,35 @@ async def _load_voice_session(
def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None:
"""Return the persisted client-visible reply for one completed turn."""
for turn in reversed(sess.turns):
if (
turn.turn_seq == turn_seq
and turn.speaker == "client"
and turn.is_visible_to("client")
):
text = (turn.text_masked or turn.text).strip()
return text or None
"""완료된 상담 턴 번호에 대응하는 client-visible 응답을 반환한다.
degraded 인메모리 미러는 상담자/내담자 쌍이 같은 논리 turn_seq를 쓰지만,
DB의 app.turns.seq는 발화마다 1 증가한다. DB 스냅샷에서 논리 1턴을
그대로 seq=1 찾으면 상담자 발화만 잡혀 TTS가 404 되므로 저장 형태를
명시적으로 구분한다.
"""
visible_clients = [
turn
for turn in sess.turns
if turn.speaker == "client" and turn.is_visible_to("client")
]
counselor_sequences = {
turn.turn_seq for turn in sess.turns if turn.speaker == "counselor"
}
paired_sequences = counselor_sequences.intersection(
turn.turn_seq for turn in visible_clients
)
if paired_sequences or not counselor_sequences:
match = next(
(turn for turn in reversed(visible_clients) if turn.turn_seq == turn_seq),
None,
)
else:
index = turn_seq - 1
match = visible_clients[index] if 0 <= index < len(visible_clients) else None
if match is not None:
text = (match.text_masked or match.text).strip()
return text or None
return None

View file

@ -1,8 +1,8 @@
"""음성 캐스케이드 — OpenAI STT(전사) + TTS(멀티보이스) 어댑터.
"""음성 캐스케이드 — OpenAI STT + OpenAI/Higgs TTS 어댑터.
MASTERPLAN '음성 필수'(한신대 요구) / DESIGN_CONCEPT §5.2(음성 오브 4상태) / §4.3(립싱크 RMS):
STT : OpenAI /v1/audio/transcriptions (gpt-4o-transcribe | whisper-1). 학습자 음성 텍스트.
TTS : OpenAI /v1/audio/speech (gpt-4o-mini-tts | tts-1). 내담자 텍스트 음성(페르소나 voice).
TTS : OpenAI /v1/audio/speech 또는 로컬 Higgs v3. 내담자 텍스트 음성.
설계 원칙( 모듈의 경계):
- 순수 어댑터: httpx OpenAI 음성 엔드포인트만 호출한다. 상담 로직(orchestrator)·상태머신은
@ -34,6 +34,7 @@ from ..paths import repo_root, repo_path
OPENAI_BASE_URL = "https://api.openai.com/v1"
STT_ENDPOINT = "/audio/transcriptions"
TTS_ENDPOINT = "/audio/speech"
HIGGS_TTS_ENDPOINT = "/tts"
# STT 모델: gpt-4o-transcribe(고품질) — 미가용 폴백은 whisper-1.
STT_MODEL = "gpt-4o-transcribe"
@ -41,6 +42,7 @@ STT_MODEL_FALLBACK = "whisper-1"
# TTS 모델: gpt-4o-mini-tts(저지연·표현력) — 폴백 tts-1.
TTS_MODEL = "gpt-4o-mini-tts"
TTS_MODEL_FALLBACK = "tts-1"
HIGGS_TTS_MODEL = "higgs-audio-v3-tts-4b"
# 전사 언어 힌트(상담은 한국어). OpenAI 는 ISO-639-1.
STT_LANGUAGE = "ko"
@ -76,6 +78,25 @@ _POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
),
)
_HIGGS_DELIVERY_TAGS: tuple[tuple[tuple[str, ...], str], ...] = (
(
("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "갈래"),
"<|emotion:fear|><|prosody:speed_fast|><|prosody:pitch_high|>",
),
(
("", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"),
"<|emotion:sadness|><|prosody:speed_slow|><|prosody:expressive_low|>",
),
(
("오늘은", "친구", "", "괜찮았", "좋았", "해냈"),
"<|emotion:contentment|><|prosody:speed_fast|>",
),
(
("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"),
"<|emotion:relief|><|prosody:speed_slow|>",
),
)
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
@ -236,6 +257,35 @@ def resolve_voice_from_map(
)
def build_higgs_prompt(text: str, voice: VoicePreset) -> str:
"""합성 seed의 화자 정체성을 지키면서 감정·속도 태그를 첫 단어 뒤에 넣는다."""
normalized = text.casefold()
tags = ""
if voice.preset == POC_SAMPLE_TTS_PRESET:
for keywords, candidate in _HIGGS_DELIVERY_TAGS:
if any(keyword.casefold() in normalized for keyword in keywords):
tags = candidate
break
if not tags:
tags = (
"<|emotion:helplessness|><|prosody:speed_slow|>"
"<|prosody:expressive_low|>"
)
elif voice.rate <= 0.85:
tags = "<|prosody:speed_slow|>"
elif voice.rate >= 1.15:
tags = "<|prosody:speed_fast|>"
if not tags:
return text
# Higgs 강한 감정 태그를 맨 앞에 두면 reference 화자가 흔들릴 수 있다. 첫 단어로
# 화자를 먼저 고정한 뒤 태그 다음 단어를 공백 없이 이어 붙인다.
match = re.match(r"^(\S+\s+)(.+)$", text, flags=re.DOTALL)
if match:
return f"{match.group(1)}{tags}{match.group(2).lstrip()}"
return tags + text
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
@ -311,7 +361,7 @@ def assess_end_of_turn(
# OpenAI 음성 서비스
# ════════════════════════════════════════════════════════════════════════════
class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
"""OpenAI STT와 선택형 OpenAI/Higgs TTS 어댑터(httpx 풀 공유)."""
def __init__(
self,
@ -321,10 +371,27 @@ class VoiceService:
poc_sample_tts_enabled: Optional[bool] = None,
environment: Optional[str] = None,
poc_sample_tts_dir: Optional[str | Path] = None,
tts_provider: Optional[str] = None,
higgs_base_url: Optional[str] = None,
higgs_timeout_seconds: Optional[float] = None,
) -> None:
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
self._environment = environment if environment is not None else settings.environment
self._tts_provider = (
tts_provider if tts_provider is not None else settings.voice_tts_provider
).strip().lower()
self._higgs_base_url = (
higgs_base_url if higgs_base_url is not None else settings.higgs_tts_url
).rstrip("/")
self._higgs_timeout_seconds = max(
1.0,
float(
higgs_timeout_seconds
if higgs_timeout_seconds is not None
else settings.higgs_tts_timeout_seconds
),
)
self._poc_sample_tts_enabled = (
bool(settings.voice_poc_sample_tts_enabled)
if poc_sample_tts_enabled is None
@ -340,35 +407,77 @@ class VoiceService:
sample_dir = repo_root() / sample_dir
self._poc_sample_tts_dir = sample_dir
self._client: Optional[httpx.AsyncClient] = None
self._higgs_client: Optional[httpx.AsyncClient] = None
# ── 수명주기 ──────────────────────────────────────────
async def startup(self) -> None:
if not self._api_key:
return # 키 없으면 클라이언트도 안 띄움(degraded). 라우트가 503 처리.
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
if self._api_key:
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
if self._higgs_enabled():
self._higgs_client = httpx.AsyncClient(
base_url=self._higgs_base_url,
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
if self._higgs_client is not None:
await self._higgs_client.aclose()
self._higgs_client = None
def is_available(self) -> bool:
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
"""마이크 캐스케이드(STT+TTS) 전체 가용 여부."""
return self.stt_available() and self.tts_available()
def stt_available(self) -> bool:
return bool(self._api_key)
def tts_available(self, voice: VoicePreset | None = None) -> bool:
if self._higgs_enabled() and (voice is None or self._should_use_higgs_tts(voice)):
return True
if self._poc_sample_tts_available():
return True
return bool(self._api_key)
def tts_provider(self) -> str:
if self._higgs_enabled():
return "higgs"
if self._poc_sample_tts_available():
return "p1-sample-poc"
if self._tts_provider == "higgs" and self._environment != "dev":
return "disabled-non-dev"
if self._api_key:
return "openai"
if self._poc_sample_tts_enabled and self._environment != "dev":
return "disabled-non-dev"
return "unavailable"
def tts_provider_for_voice(self, voice: VoicePreset) -> str:
if self._should_use_higgs_tts(voice):
return "higgs"
if self._should_use_poc_sample_tts(voice):
return "p1-sample-poc"
return "openai" if self._api_key else "unavailable"
def tts_model_for_voice(self, voice: VoicePreset) -> str:
return HIGGS_TTS_MODEL if self._should_use_higgs_tts(voice) else TTS_MODEL
def tts_media_type_for_voice(self, voice: VoicePreset) -> str:
return "audio/wav" if self._should_use_higgs_tts(voice) else "audio/mpeg"
def _higgs_enabled(self) -> bool:
return self._tts_provider == "higgs" and self._environment == "dev"
def _should_use_higgs_tts(self, voice: VoicePreset) -> bool:
# 현재 권리 안전한 synthetic reference는 P1 서연 프리셋만 보유한다.
return self._higgs_enabled() and voice.preset == POC_SAMPLE_TTS_PRESET
def poc_sample_tts_available(self) -> bool:
return self._poc_sample_tts_available()
@ -402,6 +511,17 @@ class VoiceService:
)
return self._client
@property
def _higgs_http(self) -> httpx.AsyncClient:
if not self._higgs_enabled():
raise VoiceUnavailable("Higgs TTS는 로컬 dev 환경에서만 사용할 수 있습니다.")
if self._higgs_client is None:
self._higgs_client = httpx.AsyncClient(
base_url=self._higgs_base_url,
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
)
return self._higgs_client
# ── STT (transcriptions) ─────────────────────────────
async def transcribe(
self,
@ -456,16 +576,20 @@ class VoiceService:
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> AsyncIterator[TTSChunk]:
"""텍스트 → 음성 스트리밍(OpenAI /audio/speech). 청크 + RMS 힌트 yield.
"""텍스트 → 음성 스트리밍(OpenAI 또는 로컬 Higgs). 오디오 청크를 yield한다.
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크) 같이 보낸다.
없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
선택 provider가 준비되지 않으면 VoiceUnavailable, 전송 오류는 RuntimeError로 전파한다.
"""
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
text = speakable_text(text)
if not text:
return
if self._should_use_higgs_tts(voice):
async for chunk in self._synthesize_higgs_tts(text, voice):
yield chunk
return
if self._should_use_poc_sample_tts(voice):
async for chunk in self._synthesize_poc_sample_tts(text):
yield chunk
@ -516,6 +640,31 @@ class VoiceService:
if chunk:
yield TTSChunk(audio=chunk)
async def _synthesize_higgs_tts(
self, text: str, voice: VoicePreset
) -> AsyncIterator[TTSChunk]:
payload = {
"text": build_higgs_prompt(text, voice),
"preset": voice.preset,
}
try:
async with self._higgs_http.stream(
"POST", HIGGS_TTS_ENDPOINT, json=payload
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes(chunk_size=POC_SAMPLE_TTS_CHUNK_SIZE):
if chunk:
yield TTSChunk(audio=chunk)
except httpx.HTTPStatusError as exc:
body = ""
try:
body = (await exc.response.aread()).decode("utf-8", "ignore")[:200]
except Exception:
pass
raise RuntimeError(f"Higgs TTS {exc.response.status_code}: {body}") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"Higgs TTS transport error: {exc}") from exc
def _select_poc_sample_id(self, text: str) -> str:
normalized = text.casefold()
for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS:
@ -576,6 +725,7 @@ __all__ = [
"resolve_voice",
"resolve_voice_from_map",
"build_tts_payload",
"build_higgs_prompt",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",
"PRESET_TO_OPENAI_VOICE",
@ -583,4 +733,5 @@ __all__ = [
"DEFAULT_OPENAI_VOICE",
"STT_MODEL",
"TTS_MODEL",
"HIGGS_TTS_MODEL",
]

View file

@ -6,6 +6,9 @@ import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .contracts.engine_gateway import EngineCapabilitiesResponse, EngineModelOption
from .deps import Principal, Role
from .routes import admin as admin_routes
@ -572,3 +575,224 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("measure_name IN ('self_efficacy','skill_proficiency','training_satisfaction')", schema)
self.assertIn("ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY", schema)
self.assertIn("CREATE POLICY p_learner_prepost_measure_insert", schema)
class AdminEngineConfigTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.previous_config = admin_routes._ENGINE_CONFIG
admin_routes._ENGINE_CONFIG = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
reasoning_effort="high",
durable=True,
source="database",
)
self.principal = Principal(
user_id="00000000-0000-0000-0000-000000000099",
role=Role.ADMIN,
cohort_ids=[],
email="admin@twentyoz.kr",
display_name="Admin",
)
async def asyncTearDown(self) -> None:
admin_routes._ENGINE_CONFIG = self.previous_config
async def test_capabilities_endpoint_uses_requested_gateway_url(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[
EngineModelOption(
id="gpt-5.6-terra",
label="GPT-5.6-Terra",
reasoning_efforts=["medium"],
default_reasoning_effort="medium",
)
],
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
fetched_at=1,
)
with patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
) as lookup:
result = await admin_routes.get_engine_capabilities(
self.principal,
engine_mode="codex_cli",
engine_url="http://127.0.0.1:9199/",
force=True,
)
self.assertEqual(result.default_model, "gpt-5.6-terra")
lookup.assert_awaited_once_with(
provider="codex_cli",
base_url="http://127.0.0.1:9199",
force=True,
)
with patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
) as current_lookup:
await admin_routes.get_engine_capabilities(
self.principal,
engine_mode="codex_cli",
engine_url=None,
force=False,
)
current_lookup.assert_awaited_once_with(
provider="codex_cli",
base_url="http://127.0.0.1:9099",
force=False,
)
async def test_patch_rejects_model_missing_from_live_catalog(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[],
fetched_at=1,
)
with patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
):
with self.assertRaises(HTTPException) as caught:
await admin_routes.patch_engine_config(
admin_routes.AdminEngineConfigPatch(
engine_mode="codex_cli",
model="made-up-model",
reasoning_effort="medium",
),
self.principal,
)
self.assertEqual(caught.exception.status_code, 422)
self.assertIn("사용할 수 없는 모델", caught.exception.detail)
async def test_patch_persists_and_applies_reasoning_effort(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[
EngineModelOption(
id="gpt-5.6-terra",
label="GPT-5.6-Terra",
reasoning_efforts=["low", "medium", "high"],
default_reasoning_effort="medium",
)
],
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
fetched_at=1,
)
class ConfigConn:
def __init__(self):
self.query = ""
self.args = ()
async def fetchrow(self, query, *args):
self.query = query
self.args = args
return {
"engine_mode": args[0],
"engine_url": args[1],
"model": args[2],
"reasoning_effort": args[3],
"updated_by": args[4],
"updated_at": None,
}
conn = ConfigConn()
class ConfigPool:
def acquire(self):
return _Acquire(conn)
with (
patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(admin_routes, "get_pool", return_value=ConfigPool()),
patch.object(
admin_routes.engine_client,
"configure",
AsyncMock(),
) as configure,
):
result = await admin_routes.patch_engine_config(
admin_routes.AdminEngineConfigPatch(
engine_mode="codex_cli",
engine_url="http://127.0.0.1:9099",
model="gpt-5.6-terra",
reasoning_effort="medium",
),
self.principal,
)
self.assertEqual(result.reasoning_effort, "medium")
self.assertIn("reasoning_effort", conn.query)
self.assertEqual(conn.args[3], "medium")
configure.assert_awaited_once_with(
base_url="http://127.0.0.1:9099",
engine_mode="codex_cli",
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
)
async def test_patch_can_explicitly_clear_reasoning_effort(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="claude_api",
available=True,
source="live_api",
models=[EngineModelOption(id="claude-legacy", label="Claude Legacy")],
default_model="claude-legacy",
fetched_at=1,
)
class ConfigConn:
async def fetchrow(self, _query, *args):
return {
"engine_mode": args[0],
"engine_url": args[1],
"model": args[2],
"reasoning_effort": args[3],
"updated_by": args[4],
"updated_at": None,
}
class ConfigPool:
def acquire(self):
return _Acquire(ConfigConn())
with (
patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(admin_routes, "get_pool", return_value=ConfigPool()),
patch.object(admin_routes.engine_client, "configure", AsyncMock()),
):
result = await admin_routes.patch_engine_config(
admin_routes.AdminEngineConfigPatch(
engine_mode="claude_api",
model="claude-legacy",
reasoning_effort=None,
),
self.principal,
)
self.assertIsNone(result.reasoning_effort)

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import unittest
import base64
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any
from urllib.parse import parse_qs, urlencode, urlsplit
@ -390,6 +391,36 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
hoonjung_role = auth_routes._role_for_email("hoonjungkoo@hs.ac.kr")
self.assertEqual(hoonjung_role, Role.ADMIN)
async def test_existing_primary_role_super_admin_persists_admin_access_on_login(
self,
) -> None:
email = "persistent-root@twentyoz.kr"
existing = _managed_user(email=email, role="learner", admin_access=False)
auth_sessions._users[existing.user_id] = existing
auth_sessions._email_index[email] = existing.user_id
with (
patched_settings(
environment="dev",
auth_super_admin_emails=[email],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
):
_, user = await auth_sessions.create_session(
email=email,
display_name="Persistent Root",
role="learner",
external_id="google:persistent-root",
)
self.assertEqual(user.role, "learner")
self.assertTrue(user.super_admin)
self.assertTrue(user.admin_access)
self.assertTrue(auth_sessions._users[existing.user_id].admin_access)
async def test_pending_provider_user_enqueues_account_approval_notification(self) -> None:
with (
patched_settings(
@ -544,6 +575,69 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(updated.admin_access)
self.assertEqual(updated.role, "learner")
async def test_super_admin_oauth_upsert_casts_nullable_admin_access_as_boolean(
self,
) -> None:
class RecordingConn:
def __init__(self) -> None:
self.queries: list[str] = []
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
self.queries.append(query)
if len(self.queries) == 1:
return None
now = datetime.now(timezone.utc)
return {
"user_id": "00000000-0000-0000-0000-000000000605",
"email": "yunchan@twentyoz.kr",
"display_name": "Yun Chan",
"role": "learner",
"admin_access": True,
"account_status": "approved",
"cohort": "",
"affiliation": "",
"created_at": now,
"last_seen_at": now,
}
class RecordingAcquire:
def __init__(self, conn: RecordingConn) -> None:
self.conn = conn
async def __aenter__(self) -> RecordingConn:
return self.conn
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class RecordingPool:
def __init__(self, conn: RecordingConn) -> None:
self.conn = conn
def acquire(self) -> RecordingAcquire:
return RecordingAcquire(self.conn)
conn = RecordingConn()
with (
patched_settings(
auth_super_admin_emails=["yunchan@twentyoz.kr"],
auth_admin_emails=[],
),
patch.object(auth_sessions, "get_pool", return_value=RecordingPool(conn)),
):
user = await auth_sessions.upsert_managed_user(
auth_sessions.ManagedUserUpsertInput(
email="yunchan@twentyoz.kr",
display_name="Yun Chan",
role="learner",
external_id="google:111856072590637505974",
)
)
self.assertTrue(user.admin_access)
self.assertIn("$7::boolean", conn.queries[0])
self.assertIn("$9::boolean", conn.queries[1])
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
request = _request(
[

View file

@ -382,7 +382,10 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
await sessions._end_persisted_session(sess, carry)
self.assertTrue(sess.ended)
self.assertEqual(len(scheduled), 1)
self.assertEqual(
[coro.cr_code.co_name for coro in scheduled],
["_write_episodic_embeddings", "close_session"],
)
async def test_end_persisted_session_schedules_digest_worker_only_when_enabled(self) -> None:
scheduled: list[str] = []
@ -434,7 +437,11 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
scheduled,
["_run_session_digest_worker_for_session", "_write_episodic_embeddings"],
[
"_run_session_digest_worker_for_session",
"_write_episodic_embeddings",
"close_session",
],
)
async def test_end_persisted_session_keeps_digest_worker_default_off(self) -> None:
@ -485,7 +492,7 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
):
await sessions._end_persisted_session(sess, carry)
self.assertEqual(scheduled, ["_write_episodic_embeddings"])
self.assertEqual(scheduled, ["_write_episodic_embeddings", "close_session"])
async def test_session_digest_worker_releases_db_connection_during_engine_call(self) -> None:
order: list[str] = []

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
import unittest
from types import SimpleNamespace
@ -609,6 +610,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
principal,
)
await _consume_event_source(response)
if sessions._STREAM_TURN_EVALUATION_TASKS:
await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS))
self.assertEqual(len(sess.turns), 2)
learner_turn, client_turn = sess.turns
@ -620,6 +623,65 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertIsNone(client_turn.evaluation)
async def test_stream_turn_done_does_not_wait_for_fast_loop_evaluation(
self,
) -> None:
principal = _principal()
sess = _session(principal)
evaluation_started = asyncio.Event()
release_evaluation = asyncio.Event()
async def successful_stream(ctx, engine, **kwargs):
assert ctx.state_after is not None
yield orchestrator.StreamEvent("token", {"text": "지금 답할게요."})
yield orchestrator.StreamEvent(
"done",
{
"session_id": ctx.session_id,
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": False,
"llm_provider": "claude_cli",
"model": "gateway-default",
},
)
async def slow_eval_hook(ctx, client_reply):
evaluation_started.set()
await release_evaluation.wait()
return {
"loop": "fast",
"turn_seq": ctx.state_after.turn_seq,
"stage": ctx.state_after.stage.value,
"appropriateness": "pos",
}
with (
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
patch.object(
sessions.evaluator,
"make_eval_hook",
return_value=slow_eval_hook,
),
):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="평가를 기다리지 않는 발화"),
principal,
)
consume_task = asyncio.create_task(_consume_event_source(response))
await asyncio.wait_for(evaluation_started.wait(), timeout=1)
body = await asyncio.wait_for(consume_task, timeout=0.2)
self.assertIn("'event': 'done'", body.decode("utf-8"))
self.assertEqual(len(sess.turns), 2)
self.assertIsNone(sess.turns[0].evaluation)
release_evaluation.set()
if sessions._STREAM_TURN_EVALUATION_TASKS:
await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS))
self.assertEqual(sess.turns[0].evaluation["appropriateness"], "pos")
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(
self,
) -> None:
@ -662,6 +724,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
principal,
)
await _consume_event_source(response)
if sessions._STREAM_TURN_EVALUATION_TASKS:
await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS))
self.assertEqual(len(sess.turns), 2)
learner_turn = sess.turns[0]

View file

@ -12,9 +12,12 @@ from .services.voice import (
TTS_ENDPOINT,
TTS_MODEL,
TTS_MODEL_FALLBACK,
HIGGS_TTS_ENDPOINT,
HIGGS_TTS_MODEL,
VoicePreset,
VoiceService,
assess_end_of_turn,
build_higgs_prompt,
build_tts_payload,
resolve_voice,
resolve_voice_from_map,
@ -96,6 +99,16 @@ class VoicePresetResolutionTest(unittest.TestCase):
class TTSPayloadTest(unittest.TestCase):
def test_higgs_prompt_keeps_first_word_before_emotion_tags(self) -> None:
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral", rate=0.96)
prompt = build_higgs_prompt("그냥 학교 가도 아무 의미 없는 것 같아요.", voice)
self.assertTrue(prompt.startswith("그냥 "))
self.assertIn("<|emotion:helplessness|>", prompt)
self.assertIn("<|prosody:speed_slow|>", prompt)
self.assertNotIn("<|emotion:helplessness|> ", prompt)
def test_payload_contains_openai_tts_fields_and_clamps_high_speed(self) -> None:
voice = VoicePreset(
preset="soft-young-fem",
@ -219,6 +232,48 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"])
async def test_higgs_tts_uses_local_synthetic_voice_server_in_dev(self) -> None:
client = _CaptureTTSClient([b"RIFF", b"synthetic-wav"])
service = VoiceService(
api_key="",
environment="dev",
tts_provider="higgs",
higgs_base_url="http://127.0.0.1:9881",
)
service._higgs_client = client # type: ignore[assignment]
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral")
chunks = [
chunk
async for chunk in service.synthesize_stream(
"엄마한테 말하지 않는 거죠?",
voice,
)
]
self.assertFalse(service.stt_available())
self.assertTrue(service.tts_available(voice))
self.assertEqual(service.tts_provider_for_voice(voice), "higgs")
self.assertEqual(service.tts_model_for_voice(voice), HIGGS_TTS_MODEL)
self.assertEqual(service.tts_media_type_for_voice(voice), "audio/wav")
self.assertEqual(len(client.calls), 1)
method, endpoint, payload = client.calls[0]
self.assertEqual((method, endpoint), ("POST", HIGGS_TTS_ENDPOINT))
self.assertIn("<|emotion:fear|>", str(payload["text"]))
self.assertEqual(payload["preset"], "soft-young-fem")
self.assertEqual(b"".join(chunk.audio for chunk in chunks), b"RIFFsynthetic-wav")
async def test_higgs_tts_is_fail_closed_outside_dev(self) -> None:
service = VoiceService(
api_key="",
environment="prod",
tts_provider="higgs",
)
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral")
self.assertFalse(service.tts_available(voice))
self.assertEqual(service.tts_provider(), "disabled-non-dev")
async def test_dev_p1_sample_tts_streams_local_mp3_without_openai_key(self) -> None:
with TemporaryDirectory() as tmp:
sample_dir = Path(tmp)

View file

@ -166,6 +166,48 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99))
def test_text_tts_maps_logical_turn_to_db_transcript_sequence(self) -> None:
session = SimpleNamespace(
turns=[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage="초기",
text="첫 질문",
text_masked="첫 질문",
),
TurnRecord(
turn_seq=2,
speaker="client",
stage="초기",
text="첫 응답",
text_masked="첫 응답",
),
TurnRecord(
turn_seq=3,
speaker="counselor",
stage="초기",
text="둘째 질문",
text_masked="둘째 질문",
),
TurnRecord(
turn_seq=4,
speaker="client",
stage="초기",
text="둘째 응답",
text_masked="둘째 응답",
),
]
)
self.assertEqual(
voice_routes._client_turn_text_for_speech(session, 1), "첫 응답"
)
self.assertEqual(
voice_routes._client_turn_text_for_speech(session, 2), "둘째 응답"
)
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(
self,
) -> None:

View file

@ -105,7 +105,7 @@ async def record_completed_turn(
*,
context_prefix: str,
counselor_turn: TurnRecord | None = None,
) -> None:
) -> TurnRecord:
"""상담자 발화와 내담자 응답을 한 번에 기록하고 상태를 갱신한다."""
assert ctx.state_after is not None
learner_turn = counselor_turn or TurnRecord(
@ -144,6 +144,7 @@ async def record_completed_turn(
result.state_after,
context=f"{context_prefix} state update",
)
return learner_turn
async def record_safety_event(
@ -267,17 +268,20 @@ async def finalize_completed_turn(
*,
context_prefix: str,
counselor_turn: TurnRecord | None = None,
) -> None:
recharge_live_coach: bool = True,
) -> TurnRecord:
"""Persist a completed turn and emit any derived safety alert in route-safe order."""
await record_completed_turn(
learner_turn = await record_completed_turn(
sess,
ctx,
result,
context_prefix=context_prefix,
counselor_turn=counselor_turn,
)
await maybe_recharge_live_coach_credit(sess, ctx, result)
if recharge_live_coach:
await maybe_recharge_live_coach_credit(sess, ctx, result)
await record_safety_event(sess, ctx, result)
return learner_turn
__all__ = [

View file

@ -1,19 +1,46 @@
# 엔진 게이트웨이
로컬 claude -p(Opus 4.8) 상주 멀티턴 풀. **컨테이너 밖(호스트)** 실행, api가 `ENGINE_URL`로 호출.
API가 `ENGINE_URL`로 호출하는 호스트 실행형 AI 공급자 게이트웨이. Claude CLI 상주 풀과
Anthropic API, Codex CLI, Agy CLI를 하나의 `/v1/generate`·`/v1/stream` 계약으로 라우팅한다.
## 실행
```
cd apps/api
```powershell
cd apps\api
python -m uvicorn engine_gateway.gateway:app --host 127.0.0.1 --port 9099
```
## API
- `POST /session {system_prompt, budget_usd}` -> `{session_id}` (회기=프로세스 1개)
- `POST /session/{id}/turn {content}` -> `{text, cost_usd, turns}`
- `DELETE /session/{id}`
- `GET /health`
## 공급자와 모델 탐색
## 검증 (2026-06-25)
세션 생성+멀티턴 2턴(서연 페르소나) 컨텍스트 유지 + 캐시 재사용 비용절감 실동작 확인.
환경변수: `CLAUDE_BIN`, `ENGINE_MODEL`(비우면 Opus4.8), `ENGINE_FALLBACK_MODEL`, `SESSION_BUDGET_USD`.
| 공급자 | 모델 원천 | 기본값 | 실행 방식 |
| --- | --- | --- | --- |
| `claude_cli` | CLI가 목록 명령을 제공하지 않아 공식 alias 정적 목록 | CLI 기본 / High | 기존 `claude -p` 상주 풀 |
| `claude_api` | Anthropic `GET /v1/models` | API 목록 첫 모델 / 지원 effort | Messages API |
| `codex_cli` | Codex app-server `model/list` | `gpt-5.6-terra` / Medium | 격리 cwd의 ephemeral `codex exec` |
| `agy_cli` | `agy models` | `gemini-3.6-flash-high` / High | 격리 cwd의 `agy --print --output-format stream-json` |
| `openai`, `solar` | 현재 어댑터 없음 | 없음 | 사용할 수 없음으로 명시 |
모델 목록은 60초 캐시하며 관리자가 강제 새로고침할 수 있다. 저장할 때 선택한 공급자·모델·추론
강도를 게이트웨이가 다시 검증하므로 임의 문자열이나 사용할 수 없는 조합은 운영값으로 들어가지 않는다.
## API
- `GET /health` — 얕은 프로세스 liveness
- `GET /ready?provider=&model=&reasoning_effort=` — 선택 조합으로 실제 생성 readiness 확인
- `GET /v1/capabilities?provider=&force=` — 모델·추론 강도 카탈로그
- `POST /v1/generate` — 단발 생성
- `POST /v1/stream` — SSE `token`/`done`/`error`; Claude CLI partial-message delta와 Agy stream-json delta를 실시간 전달
- `/session` 계열 — 명시 생성 없이도 첫 client stream에서 자동 바인딩되는 Claude CLI 회기별 상주 프로세스 풀
## 환경변수
- `CLAUDE_BIN`, `CODEX_BIN`, `AGY_BIN` — CLI 경로. Windows Codex는 npm shim보다 실제 native exe를 우선 탐색한다.
- `ANTHROPIC_API_KEY`, `ANTHROPIC_API_BASE` — Anthropic 모델 조회·Messages API.
- `ENGINE_CLI_CWD` — Codex/Agy 격리 작업 폴더. 기본은 시스템 임시 폴더의 `vignette-engine-runtime`.
- `ENGINE_CAPABILITY_CACHE_TTL_SECONDS` — 모델 카탈로그 TTL, 기본 60초.
- `ENGINE_CLI_TIMEOUT_SECONDS` — CLI 생성 상한, 기본 300초.
- `ENGINE_MODEL`, `ENGINE_FALLBACK_MODEL`, `SESSION_BUDGET_USD` — 기존 Claude CLI 풀 설정.
Windows의 Agy는 `--print` 프롬프트가 명령줄 인자여서 24,000자를 넘는 요청을 fail-closed한다.
대화 conversation id는 로컬 저장·삭제 수명주기 계약이 없어 재사용하지 않고 stateless stream으로 실행한다.
Anthropic API는 키가 없으면 사용할 수 없음으로 표시한다.

View file

@ -25,15 +25,24 @@ from app.contracts.engine_gateway import (
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,
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)
@ -45,6 +54,8 @@ 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"
@ -53,13 +64,18 @@ GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
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) 안에 있음을 알아채 캐릭터를
@ -75,21 +91,26 @@ class EngineSession:
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()
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:
@ -135,6 +156,7 @@ class EngineSession:
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)
self.turns += 1
error_detail = (
@ -186,7 +208,18 @@ class EngineSession:
except json.JSONDecodeError:
continue
t = obj.get("type")
if t == "assistant":
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", "")
@ -202,6 +235,7 @@ class EngineSession:
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)
self.turns += 1
error_detail = (
@ -234,7 +268,8 @@ class EngineSession:
SESSIONS: dict[str, EngineSession] = {}
_READY_CACHE: dict[str, Any] = {"checked_at": 0.0, "ok": False, "detail": "not checked"}
_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")
@ -250,18 +285,36 @@ class TurnReq(BaseModel):
@app.get("/health")
async def health():
return {"ok": True, "engine": "claude_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)}
return {
"ok": True,
"engine": "claude_cli",
"model": DEFAULT_MODEL or "gateway-default",
"sessions": len(SESSIONS),
}
def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse:
ok = bool(_READY_CACHE.get("ok"))
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": "claude_p",
"model": DEFAULT_MODEL or "default(opus-4-8)",
"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": _READY_CACHE.get("detail"),
"detail": entry.get("detail"),
"age_seconds": round(max(0.0, age_seconds), 3),
"cached": cached,
},
@ -270,43 +323,105 @@ def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse:
@app.get("/ready")
async def ready(force: bool = False):
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.
"""
age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0)
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_TTL_SECONDS:
return _ready_response(cached=True, age_seconds=age)
return _ready_response(
entry,
provider=provider,
model=model,
reasoning_effort=reasoning_effort,
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,
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_TTL_SECONDS:
return _ready_response(
entry,
provider=provider,
model=model,
reasoning_effort=reasoning_effort,
cached=True,
age_seconds=age,
)
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()
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 = 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()
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)
_READY_CACHE.update({"checked_at": time.monotonic(), "ok": ok, "detail": detail})
return _ready_response(cached=False)
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")
@ -344,10 +459,14 @@ async def close_session(sid: str):
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":
system_parts.append(m.content)
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)
@ -361,7 +480,15 @@ def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = 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()
@ -369,11 +496,18 @@ def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None)
continue
speaker = "상담자" if m.role == "user" else "내담자"
history_parts.append(f"{speaker}: {content}")
history_sections = list(current_sections[:-1])
if history_parts:
user_payload = "[직전 대화]\n" + "\n".join(history_parts) + "\n\n[이번 상담자 발화]\n" + last_user
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)
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:
@ -397,26 +531,90 @@ def _response_model_name(session: EngineSession) -> str:
async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[EngineSession, bool]:
"""session_id 가 있고 살아있으면 재사용, 아니면 단발용 임시 세션 생성.
"""내담자 회기는 session_id 에 바인딩하고, 나머지는 단발 세션으로 실행한다.
반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다.
"""
requested_model = normalize_engine_gateway_model(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:
if requested_model is None or (s.model or DEFAULT_MODEL) == requested_model:
return s, False
# 단발(또는 죽은 세션) → 1회성 세션
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 반환."""
@ -425,9 +623,33 @@ async def v1_generate(req: GwGenerateReq):
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(prompt_parts.user_payload, timeout=GENERATE_TURN_TIMEOUT_SECONDS)
result = await s.turn(
_session_turn_payload(s, prompt_parts),
timeout=GENERATE_TURN_TIMEOUT_SECONDS,
)
finally:
if ephemeral:
await s.close()
@ -461,11 +683,53 @@ async def v1_stream(req: GwGenerateReq):
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(prompt_parts.user_payload, timeout=600.0):
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,

View file

@ -1,16 +1,49 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://vignette.local/schemas/engine_gateway_contract.v1.json",
"title": "EngineGatewayGoldenContract",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"generate_request",
"generate_response",
"stream_frames",
"stream_packets",
"compatibility_lines"
],
"properties": {
"version": {
"const": 1
},
"generate_request": {
"$ref": "#/$defs/GenerateRequest"
},
"generate_response": {
"$ref": "#/$defs/GenerateResponse"
},
"stream_frames": {
"type": "array",
"items": {
"type": "string"
}
},
"stream_packets": {
"type": "array",
"items": {
"$ref": "#/$defs/StreamPacket"
}
},
"compatibility_lines": {
"type": "array",
"items": {
"type": "string"
}
}
},
"$defs": {
"EngineMessage": {
"properties": {
"cache": {
"default": false,
"title": "Cache",
"type": "boolean"
},
"content": {
"title": "Content",
"type": "string"
},
"role": {
"enum": [
"system",
@ -19,6 +52,15 @@
],
"title": "Role",
"type": "string"
},
"content": {
"title": "Content",
"type": "string"
},
"cache": {
"default": false,
"title": "Cache",
"type": "boolean"
}
},
"required": [
@ -40,11 +82,6 @@
"title": "Ai Role",
"type": "string"
},
"max_tokens": {
"default": 1024,
"title": "Max Tokens",
"type": "integer"
},
"messages": {
"items": {
"$ref": "#/$defs/EngineMessage"
@ -52,9 +89,25 @@
"title": "Messages",
"type": "array"
},
"metadata": {
"title": "Metadata",
"type": "object"
"provider": {
"anyOf": [
{
"enum": [
"claude_cli",
"claude_api",
"codex_cli",
"agy_cli",
"openai",
"solar"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Provider"
},
"model": {
"anyOf": [
@ -68,9 +121,17 @@
"default": null,
"title": "Model"
},
"session_id": {
"reasoning_effort": {
"anyOf": [
{
"enum": [
"low",
"medium",
"high",
"xhigh",
"max",
"ultra"
],
"type": "string"
},
{
@ -78,7 +139,17 @@
}
],
"default": null,
"title": "Session Id"
"title": "Reasoning Effort"
},
"max_tokens": {
"default": 1024,
"title": "Max Tokens",
"type": "integer"
},
"temperature": {
"default": 0.7,
"title": "Temperature",
"type": "number"
},
"structured_schema": {
"anyOf": [
@ -92,10 +163,21 @@
"default": null,
"title": "Structured Schema"
},
"temperature": {
"default": 0.7,
"title": "Temperature",
"type": "number"
"session_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Session Id"
},
"metadata": {
"title": "Metadata",
"type": "object"
}
},
"required": [
@ -106,6 +188,28 @@
},
"GenerateResponse": {
"properties": {
"text": {
"title": "Text",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
},
"tokens_in": {
"default": 0,
"title": "Tokens In",
"type": "integer"
},
"tokens_out": {
"default": 0,
"title": "Tokens Out",
"type": "integer"
},
"cost_usd": {
"default": 0.0,
"title": "Cost Usd",
@ -123,14 +227,6 @@
"default": null,
"title": "Inference Geo"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
},
"structured": {
"anyOf": [
{
@ -142,20 +238,6 @@
],
"default": null,
"title": "Structured"
},
"text": {
"title": "Text",
"type": "string"
},
"tokens_in": {
"default": 0,
"title": "Tokens In",
"type": "integer"
},
"tokens_out": {
"default": 0,
"title": "Tokens Out",
"type": "integer"
}
},
"required": [
@ -166,21 +248,29 @@
"title": "GenerateResponse",
"type": "object"
},
"StreamTokenEvent": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "StreamTokenEvent",
"type": "object"
},
"StreamDoneEvent": {
"properties": {
"cost_usd": {
"default": 0.0,
"title": "Cost Usd",
"type": "number"
"provider": {
"title": "Provider",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
},
"tokens_in": {
"default": 0,
"title": "Tokens In",
@ -191,6 +281,11 @@
"title": "Tokens Out",
"type": "integer"
},
"cost_usd": {
"default": 0.0,
"title": "Cost Usd",
"type": "number"
},
"turns": {
"default": 0,
"title": "Turns",
@ -220,7 +315,12 @@
"StreamPacket": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"event",
"payload"
],
"properties": {
"event": {
"const": "token"
@ -228,15 +328,15 @@
"payload": {
"$ref": "#/$defs/StreamTokenEvent"
}
},
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"event",
"payload"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"event": {
"const": "done"
@ -244,15 +344,15 @@
"payload": {
"$ref": "#/$defs/StreamDoneEvent"
}
},
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"event",
"payload"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"event": {
"const": "error"
@ -260,71 +360,11 @@
"payload": {
"$ref": "#/$defs/StreamErrorEvent"
}
},
"required": [
"event",
"payload"
],
"type": "object"
}
}
]
},
"StreamTokenEvent": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "StreamTokenEvent",
"type": "object"
}
},
"$id": "https://vignette.local/schemas/engine_gateway_contract.v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"compatibility_lines": {
"items": {
"type": "string"
},
"type": "array"
},
"generate_request": {
"$ref": "#/$defs/GenerateRequest"
},
"generate_response": {
"$ref": "#/$defs/GenerateResponse"
},
"stream_frames": {
"items": {
"type": "string"
},
"type": "array"
},
"stream_packets": {
"items": {
"$ref": "#/$defs/StreamPacket"
},
"type": "array"
},
"version": {
"const": 1
}
},
"required": [
"version",
"generate_request",
"generate_response",
"stream_frames",
"stream_packets",
"compatibility_lines"
],
"title": "EngineGatewayGoldenContract",
"type": "object",
"x-engine-gateway-sse-events": [
"token",
"done",

View file

@ -0,0 +1,836 @@
"""Provider 탐색과 Claude CLI 이외 실행 어댑터.
Provider별 CLI/API 세부 구현은 게이트웨이가 소유한다. 애플리케이션과 맞닿는
wire 계약은 ``app.contracts.engine_gateway`` 유지한다.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, AsyncIterator, Iterable, Literal, cast
import httpx
from app.contracts.engine_gateway import (
ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
ENGINE_PROVIDER_DEFAULTS,
ENGINE_REASONING_EFFORTS,
EngineCapabilitiesResponse,
EngineModelOption,
EngineProvider,
GenerateRequest,
ReasoningEffort,
normalize_engine_gateway_model,
)
CODEX_DEFAULT_MODEL, CODEX_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS["codex_cli"]
AGY_DEFAULT_MODEL, AGY_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS["agy_cli"]
CLAUDE_CLI_DEFAULT_MODEL, CLAUDE_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS[
"claude_cli"
]
CAPABILITY_CACHE_TTL_SECONDS = float(
os.environ.get("ENGINE_CAPABILITY_CACHE_TTL_SECONDS", "60")
)
CLI_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_CLI_TIMEOUT_SECONDS", "300"))
ANTHROPIC_API_BASE = os.environ.get(
"ANTHROPIC_API_BASE", "https://api.anthropic.com"
).rstrip("/")
class ProviderError(RuntimeError):
"""자격 증명을 노출하지 않고 provider 탐색·생성 실패를 전달한다."""
@dataclass(frozen=True, slots=True)
class ProviderGenerateResult:
text: str
model: str
provider: EngineProvider
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
inference_geo: str | None = None
structured: dict[str, Any] | None = None
@dataclass(frozen=True, slots=True)
class ProviderStreamEvent:
type: Literal["delta", "done"]
text: str = ""
result: ProviderGenerateResult | None = None
_CAPABILITY_CACHE: dict[EngineProvider, tuple[float, EngineCapabilitiesResponse]] = {}
_CAPABILITY_LOCK = asyncio.Lock()
def clear_capability_cache() -> None:
_CAPABILITY_CACHE.clear()
def _now() -> float:
return time.time()
def _efforts(values: Iterable[str]) -> list[ReasoningEffort]:
allowed = set(ENGINE_REASONING_EFFORTS)
return [cast(ReasoningEffort, value) for value in values if value in allowed]
def _binary(env_name: str, fallback: str) -> str | None:
configured = os.environ.get(env_name, "").strip()
if configured:
path = Path(configured)
return str(path) if path.exists() else shutil.which(configured)
if os.name == "nt":
shim = shutil.which(fallback)
if fallback == "codex" and shim:
npm_vendor_root = (
Path(shim).parent
/ "node_modules"
/ "@openai"
/ "codex"
/ "node_modules"
/ "@openai"
)
native_candidates = sorted(
npm_vendor_root.glob("codex-win32-*/vendor/*/bin/codex.exe")
)
if native_candidates:
return str(native_candidates[0])
executable = shutil.which(f"{fallback}.exe")
if executable:
return executable
return shim
return shutil.which(fallback)
def _safe_process_error(stderr: bytes, fallback: str) -> str:
detail = stderr.decode("utf-8", errors="replace").strip()
if not detail:
return fallback
return detail[-1200:]
async def _run_process(
args: list[str],
*,
input_text: str | None = None,
cwd: str | None = None,
timeout: float = CLI_TIMEOUT_SECONDS,
) -> tuple[str, str]:
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE if input_text is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(
input_text.encode("utf-8") if input_text is not None else None
),
timeout=timeout,
)
except TimeoutError as exc:
proc.kill()
await proc.wait()
raise ProviderError(f"provider 명령이 {timeout:.0f}초 안에 끝나지 않았습니다.") from exc
if proc.returncode != 0:
raise ProviderError(
_safe_process_error(stderr, f"provider 명령 실패: 종료 코드 {proc.returncode}")
)
return (
stdout.decode("utf-8", errors="replace"),
stderr.decode("utf-8", errors="replace"),
)
def _unavailable(provider: EngineProvider, detail: str) -> EngineCapabilitiesResponse:
return EngineCapabilitiesResponse(
provider=provider,
available=False,
source="unavailable",
detail=detail,
fetched_at=_now(),
)
def _display_model_name(model_id: str) -> str:
parts = model_id.split("-")
effort = parts[-1] if parts and parts[-1] in {"low", "medium", "high"} else None
if effort:
parts = parts[:-1]
words: list[str] = []
for part in parts:
if part.lower() in {"gpt", "oss"}:
words.append(part.upper())
elif any(char.isdigit() for char in part):
words.append(part)
else:
words.append(part.capitalize())
label = " ".join(words)
return f"{label} ({effort.capitalize()})" if effort else label
async def _discover_claude_cli() -> EngineCapabilitiesResponse:
if _binary("CLAUDE_BIN", "claude") is None:
return _unavailable("claude_cli", "Claude CLI를 찾을 수 없습니다.")
efforts = _efforts(("low", "medium", "high", "xhigh", "max"))
models = [
EngineModelOption(
id=CLAUDE_CLI_DEFAULT_MODEL,
label="Claude CLI 기본 모델",
description="로그인된 Claude CLI가 권장하는 기본 모델을 사용합니다.",
reasoning_efforts=efforts,
default_reasoning_effort=CLAUDE_DEFAULT_EFFORT,
is_default=True,
),
*[
EngineModelOption(
id=model,
label=f"Claude {model.capitalize()} 최신",
description="Claude CLI가 제공하는 안정 alias입니다.",
reasoning_efforts=efforts,
default_reasoning_effort=CLAUDE_DEFAULT_EFFORT,
)
for model in ("opus", "sonnet", "fable")
],
]
return EngineCapabilitiesResponse(
provider="claude_cli",
available=True,
source="static_cli",
models=models,
default_model=CLAUDE_CLI_DEFAULT_MODEL,
default_reasoning_effort=CLAUDE_DEFAULT_EFFORT,
detail="Claude CLI는 모델 목록 명령이 없어 공식 alias를 사용합니다.",
fetched_at=_now(),
)
async def _codex_model_list(binary: str) -> dict[str, Any]:
proc = await asyncio.create_subprocess_exec(
binary,
"app-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
if proc.stdin is None or proc.stdout is None:
proc.kill()
await proc.wait()
raise ProviderError("Codex app-server stdio를 열 수 없습니다.")
messages = (
{
"method": "initialize",
"id": 0,
"params": {
"clientInfo": {
"name": "vignette_engine_gateway",
"title": "Vignette Engine Gateway",
"version": "1.0.0",
}
},
},
{"method": "initialized", "params": {}},
{
"method": "model/list",
"id": 6,
"params": {"limit": 100, "includeHidden": False},
},
)
for message in messages:
proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
await proc.stdin.drain()
try:
while True:
raw = await asyncio.wait_for(proc.stdout.readline(), timeout=20)
if not raw:
raise ProviderError("Codex model/list 응답이 비어 있습니다.")
try:
message = json.loads(raw)
except json.JSONDecodeError:
continue
if message.get("id") == 6:
if message.get("error"):
raise ProviderError(str(message["error"].get("message") or message["error"]))
return cast(dict[str, Any], message.get("result") or {})
except TimeoutError as exc:
raise ProviderError("Codex model/list 응답 시간이 초과됐습니다.") from exc
finally:
if proc.stdin is not None and not proc.stdin.is_closing():
proc.stdin.close()
if proc.returncode is None:
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
proc.kill()
await proc.wait()
async def _discover_codex_cli() -> EngineCapabilitiesResponse:
binary = _binary("CODEX_BIN", "codex")
if binary is None:
return _unavailable("codex_cli", "Codex CLI를 찾을 수 없습니다.")
try:
payload = await _codex_model_list(binary)
except (OSError, ProviderError) as exc:
return _unavailable("codex_cli", f"Codex 모델 조회 실패: {exc}")
raw_models = payload.get("data") if isinstance(payload, dict) else []
models: list[EngineModelOption] = []
for item in raw_models if isinstance(raw_models, list) else []:
if not isinstance(item, dict) or item.get("hidden"):
continue
model_id = str(item.get("model") or item.get("id") or "").strip()
if not model_id:
continue
supported = item.get("supportedReasoningEfforts") or []
efforts = _efforts(
str(entry.get("reasoningEffort") or "")
for entry in supported
if isinstance(entry, dict)
)
raw_default = str(item.get("defaultReasoningEffort") or "")
default_effort = (
cast(ReasoningEffort, raw_default)
if raw_default in efforts
else (efforts[0] if efforts else None)
)
models.append(
EngineModelOption(
id=model_id,
label=str(item.get("displayName") or model_id),
description=str(item.get("description") or ""),
reasoning_efforts=efforts,
default_reasoning_effort=default_effort,
is_default=model_id == CODEX_DEFAULT_MODEL,
)
)
if not models:
return _unavailable("codex_cli", "Codex가 선택 가능한 모델을 반환하지 않았습니다.")
default_model = (
CODEX_DEFAULT_MODEL
if any(model.id == CODEX_DEFAULT_MODEL for model in models)
else next((model.id for model in models if model.is_default), models[0].id)
)
selected = next(model for model in models if model.id == default_model)
default_effort = (
CODEX_DEFAULT_EFFORT
if CODEX_DEFAULT_EFFORT in selected.reasoning_efforts
else selected.default_reasoning_effort
)
return EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=models,
default_model=default_model,
default_reasoning_effort=default_effort,
detail="Codex app-server model/list에서 실시간 조회했습니다.",
fetched_at=_now(),
)
async def _discover_agy_cli() -> EngineCapabilitiesResponse:
binary = _binary("AGY_BIN", "agy")
if binary is None:
return _unavailable("agy_cli", "Agy CLI를 찾을 수 없습니다.")
try:
stdout, _ = await _run_process([binary, "models"], timeout=30)
except (OSError, ProviderError) as exc:
return _unavailable("agy_cli", f"Agy 모델 조회 실패: {exc}")
models: list[EngineModelOption] = []
for line in stdout.splitlines():
model_id = line.strip()
if not model_id or any(char.isspace() for char in model_id):
continue
suffix = model_id.rsplit("-", 1)[-1]
if suffix in {"low", "medium", "high"}:
efforts = _efforts((suffix,))
default_effort = cast(ReasoningEffort, suffix)
else:
efforts = _efforts(("low", "medium", "high"))
default_effort = AGY_DEFAULT_EFFORT if model_id == AGY_DEFAULT_MODEL else "medium"
models.append(
EngineModelOption(
id=model_id,
label=_display_model_name(model_id),
description="Agy CLI가 현재 계정에 노출한 모델입니다.",
reasoning_efforts=efforts,
default_reasoning_effort=default_effort,
is_default=model_id == AGY_DEFAULT_MODEL,
)
)
if not models:
return _unavailable("agy_cli", "Agy가 선택 가능한 모델을 반환하지 않았습니다.")
default_model = (
AGY_DEFAULT_MODEL
if any(model.id == AGY_DEFAULT_MODEL for model in models)
else models[0].id
)
selected = next(model for model in models if model.id == default_model)
return EngineCapabilitiesResponse(
provider="agy_cli",
available=True,
source="live_cli",
models=models,
default_model=default_model,
default_reasoning_effort=selected.default_reasoning_effort,
detail="agy models에서 실시간 조회했습니다.",
fetched_at=_now(),
)
async def _discover_claude_api() -> EngineCapabilitiesResponse:
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
if not api_key:
return _unavailable("claude_api", "ANTHROPIC_API_KEY가 설정되지 않았습니다.")
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
f"{ANTHROPIC_API_BASE}/v1/models",
params={"limit": 100},
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
return _unavailable("claude_api", f"Anthropic 모델 조회 실패: {exc}")
models: list[EngineModelOption] = []
for item in payload.get("data", []) if isinstance(payload, dict) else []:
if not isinstance(item, dict):
continue
model_id = str(item.get("id") or "").strip()
if not model_id:
continue
effort_capability = (item.get("capabilities") or {}).get("effort") or {}
efforts = _efforts(
effort
for effort in ENGINE_REASONING_EFFORTS
if isinstance(effort_capability.get(effort), dict)
and effort_capability[effort].get("supported")
)
default_effort: ReasoningEffort | None = (
CLAUDE_DEFAULT_EFFORT
if CLAUDE_DEFAULT_EFFORT in efforts
else (efforts[0] if efforts else None)
)
models.append(
EngineModelOption(
id=model_id,
label=str(item.get("display_name") or model_id),
description="Anthropic Models API가 현재 키에 노출한 모델입니다.",
reasoning_efforts=efforts,
default_reasoning_effort=default_effort,
)
)
if not models:
return _unavailable("claude_api", "Anthropic이 선택 가능한 모델을 반환하지 않았습니다.")
configured_default = os.environ.get("ANTHROPIC_MODEL", "").strip()
default_model = (
configured_default
if configured_default and any(model.id == configured_default for model in models)
else models[0].id
)
selected = next(model for model in models if model.id == default_model)
selected.is_default = True
return EngineCapabilitiesResponse(
provider="claude_api",
available=True,
source="live_api",
models=models,
default_model=default_model,
default_reasoning_effort=selected.default_reasoning_effort,
detail="Anthropic /v1/models에서 실시간 조회했습니다.",
fetched_at=_now(),
)
async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
if provider == "claude_cli":
return await _discover_claude_cli()
if provider == "claude_api":
return await _discover_claude_api()
if provider == "codex_cli":
return await _discover_codex_cli()
if provider == "agy_cli":
return await _discover_agy_cli()
return _unavailable(provider, f"{provider} 어댑터는 아직 모델 탐색을 지원하지 않습니다.")
async def discover_capabilities(
provider: EngineProvider, *, force: bool = False
) -> EngineCapabilitiesResponse:
cached = _CAPABILITY_CACHE.get(provider)
if (
not force
and cached is not None
and time.monotonic() - cached[0] < CAPABILITY_CACHE_TTL_SECONDS
):
return cached[1].model_copy(deep=True)
async with _CAPABILITY_LOCK:
cached = _CAPABILITY_CACHE.get(provider)
if (
not force
and cached is not None
and time.monotonic() - cached[0] < CAPABILITY_CACHE_TTL_SECONDS
):
return cached[1].model_copy(deep=True)
result = await _discover(provider)
_CAPABILITY_CACHE[provider] = (time.monotonic(), result)
return result.model_copy(deep=True)
def _cli_prompt(system_prompt: str, user_payload: str) -> str:
parts = []
if system_prompt.strip():
parts.append("[시스템 지침]\n" + system_prompt.strip())
parts.append("[응답할 입력]\n" + user_payload.strip())
return "\n\n".join(parts)
def _cli_runtime_cwd() -> Path:
path = Path(
os.environ.get(
"ENGINE_CLI_CWD",
str(Path(tempfile.gettempdir()) / "vignette-engine-runtime"),
)
)
path.mkdir(parents=True, exist_ok=True)
return path
async def _resolve_selection(
req: GenerateRequest, provider: EngineProvider
) -> tuple[str, ReasoningEffort | None]:
capabilities = await discover_capabilities(provider)
if not capabilities.available:
raise ProviderError(capabilities.detail or f"{provider}를 사용할 수 없습니다.")
requested_model = normalize_engine_gateway_model(req.model)
model = requested_model or capabilities.default_model
option = next((item for item in capabilities.models if item.id == model), None)
if option is None:
raise ProviderError(f"{provider}에서 사용할 수 없는 모델입니다: {model}")
effort = req.reasoning_effort or option.default_reasoning_effort
if effort is not None and effort not in option.reasoning_efforts:
raise ProviderError(f"{model}에서 사용할 수 없는 추론 강도입니다: {effort}")
return option.id, effort
def _structured_or_none(text: str, req: GenerateRequest) -> dict[str, Any] | None:
if not req.structured_schema:
return None
try:
parsed = json.loads(text)
except (json.JSONDecodeError, TypeError):
return None
return parsed if isinstance(parsed, dict) else None
async def _generate_codex(
req: GenerateRequest, system_prompt: str, user_payload: str
) -> ProviderGenerateResult:
binary = _binary("CODEX_BIN", "codex")
if binary is None:
raise ProviderError("Codex CLI를 찾을 수 없습니다.")
model, effort = await _resolve_selection(req, "codex_cli")
cli_cwd = _cli_runtime_cwd()
args = [
binary,
"exec",
"--json",
"--ephemeral",
"--skip-git-repo-check",
"--ignore-user-config",
"--ignore-rules",
"--sandbox",
"read-only",
"-C",
str(cli_cwd),
"-m",
model,
]
if effort:
args += ["-c", f'model_reasoning_effort="{effort}"']
args.append("-")
stdout, _ = await _run_process(
args,
input_text=_cli_prompt(system_prompt, user_payload),
cwd=str(cli_cwd),
)
text = ""
tokens_in = 0
tokens_out = 0
for line in stdout.splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "item.completed":
item = event.get("item") or {}
if item.get("type") == "agent_message":
text = str(item.get("text") or text)
elif event.get("type") == "turn.completed":
usage = event.get("usage") or {}
tokens_in = int(usage.get("input_tokens") or 0)
tokens_out = int(usage.get("output_tokens") or 0)
elif event.get("type") in {"turn.failed", "error"}:
raise ProviderError(str(event.get("message") or event))
if not text.strip():
raise ProviderError("Codex CLI가 최종 응답을 반환하지 않았습니다.")
return ProviderGenerateResult(
text=text,
model=model,
provider="codex_cli",
tokens_in=tokens_in,
tokens_out=tokens_out,
structured=_structured_or_none(text, req),
)
async def _generate_agy(
req: GenerateRequest, system_prompt: str, user_payload: str
) -> ProviderGenerateResult:
binary = _binary("AGY_BIN", "agy")
if binary is None:
raise ProviderError("Agy CLI를 찾을 수 없습니다.")
model, effort = await _resolve_selection(req, "agy_cli")
prompt = _cli_prompt(system_prompt, user_payload)
if os.name == "nt" and len(prompt) > 24_000:
raise ProviderError(
"Agy CLI 프롬프트가 Windows 명령줄 안전 한도(24,000자)를 초과했습니다."
)
args = [binary, "--model", model, "--sandbox"]
if effort:
args += ["--effort", effort]
args += ["--print-timeout", f"{int(CLI_TIMEOUT_SECONDS)}s"]
# Agy의 --print는 바로 뒤 토큰을 프롬프트로 해석하며 stdin 입력은
# 지원하지 않는다. 옵션을 모두 앞에 두고 프롬프트를 마지막에 둔다.
args += ["--print", prompt]
stdout, _ = await _run_process(args, cwd=str(_cli_runtime_cwd()))
text = stdout.strip()
if not text:
raise ProviderError("Agy CLI가 최종 응답을 반환하지 않았습니다.")
return ProviderGenerateResult(
text=text,
model=model,
provider="agy_cli",
structured=_structured_or_none(text, req),
)
async def _stream_agy(
req: GenerateRequest, system_prompt: str, user_payload: str
) -> AsyncIterator[ProviderStreamEvent]:
"""Agy stream-json의 agent_response delta를 게이트웨이 토큰으로 전달한다.
Agy print 모드는 대화 내용을 로컬 conversation 저장소에 남길 있으므로 여기서는
--continue/--conversation을 쓰지 않는다. 회기 메모리는 요청의 마스킹된 prompt가
소유하고, 프로세스는 응답 종료한다.
"""
binary = _binary("AGY_BIN", "agy")
if binary is None:
raise ProviderError("Agy CLI를 찾을 수 없습니다.")
model, effort = await _resolve_selection(req, "agy_cli")
prompt = _cli_prompt(system_prompt, user_payload)
if os.name == "nt" and len(prompt) > 24_000:
raise ProviderError(
"Agy CLI 프롬프트가 Windows 명령줄 안전 한도(24,000자)를 초과했습니다."
)
args = [binary, "--model", model, "--sandbox"]
if effort:
args += ["--effort", effort]
args += [
"--print-timeout",
f"{int(CLI_TIMEOUT_SECONDS)}s",
"--output-format",
"stream-json",
"--print",
prompt,
]
proc = await asyncio.create_subprocess_exec(
*args,
cwd=str(_cli_runtime_cwd()),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
assert proc.stdout is not None
assert proc.stderr is not None
stderr_task = asyncio.create_task(proc.stderr.read())
emitted = ""
final_text = ""
tokens_in = 0
tokens_out = 0
result_status = ""
try:
async with asyncio.timeout(CLI_TIMEOUT_SECONDS):
while True:
raw = await proc.stdout.readline()
if not raw:
break
try:
event = json.loads(raw.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
continue
if event.get("event") == "step_update":
update = event.get("step_update") or {}
if update.get("step_type") == "agent_response":
delta = str(update.get("text_delta") or "")
if delta:
emitted += delta
yield ProviderStreamEvent(type="delta", text=delta)
elif event.get("event") == "result":
result = event.get("result") or {}
result_status = str(result.get("status") or "")
final_text = str(result.get("response") or "")
usage = result.get("usage") or {}
tokens_in = int(usage.get("input_tokens") or 0)
tokens_out = int(usage.get("output_tokens") or 0)
returncode = await proc.wait()
except TimeoutError as exc:
raise ProviderError(
f"Agy CLI 응답 시간이 {int(CLI_TIMEOUT_SECONDS)}초를 넘었습니다."
) from exc
finally:
if proc.returncode is None:
proc.kill()
await proc.wait()
stderr = await stderr_task
if returncode != 0:
raise ProviderError(_safe_process_error(stderr, f"Agy CLI exit {returncode}"))
if result_status and result_status != "SUCCESS":
raise ProviderError(f"Agy CLI 생성 실패: {result_status}")
resolved_text = final_text or emitted
if not resolved_text.strip():
raise ProviderError("Agy CLI가 최종 응답을 반환하지 않았습니다.")
if final_text and final_text.startswith(emitted):
remainder = final_text[len(emitted) :]
if remainder:
emitted += remainder
yield ProviderStreamEvent(type="delta", text=remainder)
elif not emitted:
emitted = resolved_text
yield ProviderStreamEvent(type="delta", text=resolved_text)
yield ProviderStreamEvent(
type="done",
result=ProviderGenerateResult(
text=resolved_text,
model=model,
provider="agy_cli",
tokens_in=tokens_in,
tokens_out=tokens_out,
structured=_structured_or_none(resolved_text, req),
),
)
async def _generate_claude_api(
req: GenerateRequest, system_prompt: str
) -> ProviderGenerateResult:
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
if not api_key:
raise ProviderError("ANTHROPIC_API_KEY가 설정되지 않았습니다.")
model, effort = await _resolve_selection(req, "claude_api")
messages = [
{"role": message.role, "content": message.content}
for message in req.messages
if message.role != "system"
]
payload: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens,
"temperature": req.temperature,
"messages": messages,
}
if system_prompt:
payload["system"] = system_prompt
if effort:
payload["output_config"] = {"effort": effort}
try:
async with httpx.AsyncClient(timeout=CLI_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{ANTHROPIC_API_BASE}/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
json=payload,
)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise ProviderError(f"Anthropic Messages API 호출 실패: {exc}") from exc
text = "".join(
str(block.get("text") or "")
for block in body.get("content", [])
if isinstance(block, dict) and block.get("type") == "text"
)
if not text:
raise ProviderError("Anthropic Messages API가 텍스트 응답을 반환하지 않았습니다.")
usage = body.get("usage") or {}
inference_geo = body.get("inference_geo")
return ProviderGenerateResult(
text=text,
model=str(body.get("model") or model),
provider="claude_api",
tokens_in=int(usage.get("input_tokens") or 0),
tokens_out=int(usage.get("output_tokens") or 0),
inference_geo=str(inference_geo) if inference_geo else None,
structured=_structured_or_none(text, req),
)
async def generate_with_provider(
req: GenerateRequest,
*,
system_prompt: str,
user_payload: str,
) -> ProviderGenerateResult:
provider = req.provider
if provider == "codex_cli":
return await _generate_codex(req, system_prompt, user_payload)
if provider == "agy_cli":
return await _generate_agy(req, system_prompt, user_payload)
if provider == "claude_api":
return await _generate_claude_api(req, system_prompt)
raise ProviderError(f"이 게이트웨이에서 실행할 수 없는 provider입니다: {provider}")
async def stream_with_provider(
req: GenerateRequest,
*,
system_prompt: str,
user_payload: str,
) -> AsyncIterator[ProviderStreamEvent]:
"""Provider가 제공하는 가장 이른 출력 단위를 공통 delta/done 계약으로 바꾼다."""
if req.provider == "agy_cli":
async for event in _stream_agy(req, system_prompt, user_payload):
yield event
return
result = await generate_with_provider(
req,
system_prompt=system_prompt,
user_payload=user_payload,
)
yield ProviderStreamEvent(type="delta", text=result.text)
yield ProviderStreamEvent(type="done", result=result)

View file

@ -4,7 +4,8 @@ import shutil
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from jsonschema import Draft202012Validator
@ -36,6 +37,28 @@ class _FakeProcess:
self.returncode = -9
class _StreamStdin(_FakeStdin):
def __init__(self):
self.writes = []
def write(self, value):
self.writes.append(value)
async def drain(self):
return None
class _StreamStdout:
def __init__(self, objects):
self.lines = [
(json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
for obj in objects
]
async def readline(self):
return self.lines.pop(0) if self.lines else b""
def _capture_subprocess():
captured = []
@ -159,6 +182,7 @@ class _FakeStreamSession:
def __init__(self, events, model="test-model"):
self.events = events
self.model = model
self.turns = 0
self.closed = False
async def turn_stream(self, content, timeout=600.0):
@ -194,6 +218,42 @@ class GatewayModelTest(unittest.TestCase):
self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest)
self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error"))
def test_engine_client_payload_includes_provider_model_and_reasoning_defaults(self):
client = engine_client.EngineClient("http://127.0.0.1:9099")
client.engine_mode = "codex_cli"
client.live_client_provider = None
client.default_model = "gpt-5.6-terra"
client.default_reasoning_effort = "medium"
payload = client._payload(
contract.GenerateRequest(
messages=[contract.EngineMessage(role="user", content="hello")]
)
)
self.assertEqual(payload["provider"], "codex_cli")
self.assertEqual(payload["model"], "gpt-5.6-terra")
self.assertEqual(payload["reasoning_effort"], "medium")
def test_engine_client_uses_dedicated_live_provider_without_foreign_model_defaults(self):
client = engine_client.EngineClient("http://127.0.0.1:9099")
client.engine_mode = "agy_cli"
client.default_model = "gemini-3.6-flash-high"
client.default_reasoning_effort = "high"
client.live_client_provider = "claude_cli"
payload = client._payload(
contract.GenerateRequest(
ai_role="client",
session_id="session-id",
messages=[contract.EngineMessage(role="user", content="hello")],
)
)
self.assertEqual(payload["provider"], "claude_cli")
self.assertNotIn("model", payload)
self.assertNotIn("reasoning_effort", payload)
def test_split_messages_returns_named_current_turn_prompt_parts(self):
parts = gateway._split_messages(
[
@ -209,11 +269,13 @@ class GatewayModelTest(unittest.TestCase):
self.assertIsInstance(parts, gateway.GatewayPromptParts)
self.assertEqual(parts.system_prompt, "system one\n\nsystem two")
self.assertEqual(parts.user_payload, "current client")
self.assertEqual(parts.current_user_payload, "current client")
def test_split_messages_injects_client_history_before_current_counselor_turn(self):
parts = gateway._split_messages(
[
contract.EngineMessage(role="system", content="client persona system"),
contract.EngineMessage(role="system", content="client persona system", cache=True),
contract.EngineMessage(role="system", content="dynamic state", cache=False),
contract.EngineMessage(role="user", content="상담자 이전 질문"),
contract.EngineMessage(role="assistant", content="내담자 이전 답변"),
contract.EngineMessage(role="user", content="이번 상담자 발화"),
@ -222,11 +284,15 @@ class GatewayModelTest(unittest.TestCase):
)
self.assertEqual(parts.system_prompt, "client persona system")
self.assertIn("[현재 턴 상태와 연기 지시]", parts.user_payload)
self.assertIn("dynamic state", parts.user_payload)
self.assertIn("[직전 대화]", parts.user_payload)
self.assertIn("상담자: 상담자 이전 질문", parts.user_payload)
self.assertIn("내담자: 내담자 이전 답변", parts.user_payload)
self.assertIn("[이번 상담자 발화]", parts.user_payload)
self.assertTrue(parts.user_payload.rstrip().endswith("이번 상담자 발화"))
self.assertNotIn("[직전 대화]", parts.current_user_payload)
self.assertIn("dynamic state", parts.current_user_payload)
def test_split_messages_does_not_inject_history_for_evaluator_requests(self):
parts = gateway._split_messages(
@ -250,6 +316,11 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(parts.system_prompt, "system only")
self.assertEqual(parts.user_payload, "")
self.assertEqual(parts.current_user_payload, "")
def test_claude_process_enables_real_partial_streaming_without_disk_session_copy(self):
self.assertIn("--include-partial-messages", gateway.BASE_ARGS)
self.assertIn("--no-session-persistence", gateway.BASE_ARGS)
def test_sse_frame_helper_preserves_gateway_wire_contract(self):
self.assertEqual(
@ -496,6 +567,135 @@ class GatewayModelTest(unittest.TestCase):
finally:
asyncio.run(session.close())
def test_engine_session_passes_reasoning_effort_to_claude_cli(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", ""),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session = gateway.EngineSession(
model="opus",
reasoning_effort="high",
)
asyncio.run(session.start())
try:
self.assertIn("--effort", captured[0])
self.assertEqual(
captured[0][captured[0].index("--effort") + 1], "high"
)
finally:
asyncio.run(session.close())
def test_engine_session_emits_partial_stream_events_without_final_message_duplication(self):
process = _FakeProcess()
process.stdin = _StreamStdin()
process.stdout = _StreamStdout(
[
{
"type": "stream_event",
"event": {
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": ""},
},
},
{
"type": "stream_event",
"event": {
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": "녕!"},
},
},
{
"type": "assistant",
"message": {"content": [{"type": "text", "text": "안녕!"}]},
},
{"type": "result", "is_error": False, "total_cost_usd": 0.01},
]
)
session = gateway.EngineSession()
session.proc = process
async def collect():
return [event async for event in session.turn_stream("질문")]
events = asyncio.run(collect())
self.assertEqual(
events,
[
{"type": "delta", "text": ""},
{"type": "delta", "text": "녕!"},
{
"type": "done",
"text": "안녕!",
"cost_usd": 0.01,
"turns": 1,
"is_error": False,
"error": "안녕!",
},
],
)
def test_v1_generate_routes_non_claude_provider_through_registry(self):
result = SimpleNamespace(
text="registry response",
model="gpt-5.6-terra",
provider="codex_cli",
tokens_in=12,
tokens_out=3,
cost_usd=0.0,
inference_geo=None,
structured=None,
)
request = contract.GenerateRequest(
provider="codex_cli",
model="gpt-5.6-terra",
reasoning_effort="medium",
messages=[contract.EngineMessage(role="user", content="hello")],
)
with patch.object(
gateway,
"generate_with_provider",
AsyncMock(return_value=result),
) as generate:
response = asyncio.run(gateway.v1_generate(request))
self.assertEqual(response["provider"], "codex_cli")
self.assertEqual(response["model"], "gpt-5.6-terra")
generate.assert_awaited_once()
def test_v1_stream_forwards_non_claude_provider_deltas(self):
result = SimpleNamespace(
text="안녕",
model="gemini-3.6-flash-high",
provider="agy_cli",
tokens_in=12,
tokens_out=2,
cost_usd=0.0,
)
request = contract.GenerateRequest(
provider="agy_cli",
model="gemini-3.6-flash-high",
reasoning_effort="high",
messages=[contract.EngineMessage(role="user", content="hello")],
)
async def fake_stream(*args, **kwargs):
yield SimpleNamespace(type="delta", text="", result=None)
yield SimpleNamespace(type="delta", text="", result=None)
yield SimpleNamespace(type="done", text="", result=result)
with patch.object(gateway, "stream_with_provider", fake_stream):
response = asyncio.run(gateway.v1_stream(request))
body = asyncio.run(_read_streaming_response(response))
self.assertEqual(body.count("event: token"), 2)
self.assertIn('data: {"text": ""}', body)
self.assertIn('data: {"text": ""}', body)
self.assertIn("event: done", body)
self.assertIn('"provider": "agy_cli"', body)
def test_resolve_session_does_not_reuse_session_with_different_model(self):
captured, process_patch = _capture_subprocess()
existing = gateway.EngineSession(model="old-model")
@ -512,11 +712,13 @@ class GatewayModelTest(unittest.TestCase):
)
try:
self.assertIs(ephemeral, True)
self.assertIs(ephemeral, False)
self.assertIsNot(session, existing)
self.assertEqual(_model_arg(captured[0]), "new-model")
self.assertIs(gateway.SESSIONS["sid"], existing)
self.assertIs(gateway.SESSIONS["sid"], session)
self.assertEqual(existing.proc.returncode, 0)
finally:
gateway.SESSIONS.pop("sid", None)
asyncio.run(session.close())
def test_resolve_session_reuses_live_session_id_without_starting_claude(self):
@ -538,7 +740,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertIs(ephemeral, False)
self.assertEqual(captured, [])
def test_resolve_session_creates_fresh_ephemeral_for_missing_session_id(self):
def test_resolve_session_binds_missing_client_session_id_to_resident_pool(self):
captured, process_patch = _capture_subprocess()
with (
@ -551,11 +753,12 @@ class GatewayModelTest(unittest.TestCase):
)
try:
self.assertIs(ephemeral, True)
self.assertNotIn(session.id, gateway.SESSIONS)
self.assertIs(ephemeral, False)
self.assertIs(gateway.SESSIONS["missing"], session)
self.assertEqual(len(captured), 1)
self.assertIn("--system-prompt", captured[0])
finally:
gateway.SESSIONS.pop("missing", None)
asyncio.run(session.close())
def test_v1_generate_reuses_session_id_without_ephemeral_close(self):
@ -581,7 +784,10 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(validated.text, "reused response")
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.01)
self.assertEqual(calls, [("hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)])
self.assertEqual(
calls,
[("[이번 상담자 발화]\nhello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)],
)
self.assertEqual(closes, [])
def test_v1_generate_closes_fresh_ephemeral_session(self):
@ -605,14 +811,23 @@ class GatewayModelTest(unittest.TestCase):
patch.object(gateway.EngineSession, "turn", fake_turn),
patch.object(gateway.EngineSession, "close", fake_close),
):
response = asyncio.run(gateway.v1_generate(_request(session_id="missing")))
response = asyncio.run(gateway.v1_generate(_request()))
validated = contract.GenerateResponse.model_validate(response)
self.assertEqual(validated.text, "fresh response")
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.02)
self.assertEqual(len(started), 1)
self.assertEqual(turned, [(started[0], "hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)])
self.assertEqual(
turned,
[
(
started[0],
"[이번 상담자 발화]\nhello",
gateway.GENERATE_TURN_TIMEOUT_SECONDS,
)
],
)
self.assertEqual(closed, [started[0]])
self.assertNotIn(started[0].id, gateway.SESSIONS)
@ -653,7 +868,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertIn('"provider": "claude_cli"', body)
self.assertIn('"model": "stream-model"', body)
self.assertIn('"cost_usd": 0.03', body)
self.assertEqual(session.content, "hello")
self.assertEqual(session.content, "[이번 상담자 발화]\nhello")
self.assertEqual(session.timeout, 600.0)
self.assertTrue(session.closed)

View file

@ -0,0 +1,377 @@
import json
import unittest
from unittest.mock import AsyncMock, patch
from app.contracts.engine_gateway import EngineMessage, GenerateRequest
from engine_gateway import provider_registry
class _FakeStreamReader:
def __init__(self, lines: list[bytes] | None = None, body: bytes = b""):
self.lines = list(lines or [])
self.body = body
async def readline(self) -> bytes:
return self.lines.pop(0) if self.lines else b""
async def read(self) -> bytes:
return self.body
class _FakeAgyProcess:
def __init__(self, events: list[dict]):
self.stdout = _FakeStreamReader(
[(json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8") for event in events]
)
self.stderr = _FakeStreamReader()
self.returncode = None
async def wait(self) -> int:
if self.returncode is None:
self.returncode = 0
return self.returncode
def kill(self) -> None:
self.returncode = -9
class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
provider_registry.clear_capability_cache()
def tearDown(self):
provider_registry.clear_capability_cache()
async def test_codex_catalog_uses_live_models_with_terra_medium_default(self):
payload = {
"data": [
{
"id": "gpt-5.6-sol",
"model": "gpt-5.6-sol",
"displayName": "GPT-5.6-Sol",
"description": "Frontier",
"hidden": False,
"isDefault": True,
"defaultReasoningEffort": "low",
"supportedReasoningEfforts": [
{"reasoningEffort": "low"},
{"reasoningEffort": "medium"},
],
},
{
"id": "gpt-5.6-terra",
"model": "gpt-5.6-terra",
"displayName": "GPT-5.6-Terra",
"description": "Balanced",
"hidden": False,
"isDefault": False,
"defaultReasoningEffort": "medium",
"supportedReasoningEfforts": [
{"reasoningEffort": "low"},
{"reasoningEffort": "medium"},
{"reasoningEffort": "high"},
],
},
]
}
with (
patch.object(provider_registry, "_binary", return_value="codex.exe"),
patch.object(
provider_registry,
"_codex_model_list",
AsyncMock(return_value=payload),
),
):
result = await provider_registry.discover_capabilities("codex_cli")
self.assertTrue(result.available)
self.assertEqual(result.source, "live_cli")
self.assertEqual(result.default_model, "gpt-5.6-terra")
self.assertEqual(result.default_reasoning_effort, "medium")
terra = next(model for model in result.models if model.id == "gpt-5.6-terra")
self.assertTrue(terra.is_default)
self.assertEqual(terra.reasoning_efforts, ["low", "medium", "high"])
async def test_agy_catalog_uses_cli_list_with_flash_high_default(self):
stdout = "\n".join(
[
"gemini-3.6-flash-high",
"gemini-3.6-flash-medium",
"claude-sonnet-4-6",
]
)
with (
patch.object(provider_registry, "_binary", return_value="agy.exe"),
patch.object(
provider_registry,
"_run_process",
AsyncMock(return_value=(stdout, "")),
),
):
result = await provider_registry.discover_capabilities("agy_cli")
self.assertTrue(result.available)
self.assertEqual(result.default_model, "gemini-3.6-flash-high")
self.assertEqual(result.default_reasoning_effort, "high")
selected = next(model for model in result.models if model.is_default)
self.assertEqual(selected.reasoning_efforts, ["high"])
self.assertEqual(selected.label, "Gemini 3.6 Flash (High)")
async def test_claude_cli_catalog_is_explicit_static_alias_fallback(self):
with patch.object(provider_registry, "_binary", return_value="claude.exe"):
result = await provider_registry.discover_capabilities("claude_cli")
self.assertTrue(result.available)
self.assertEqual(result.source, "static_cli")
self.assertEqual(result.default_model, "gateway-default")
self.assertEqual([model.id for model in result.models], ["gateway-default", "opus", "sonnet", "fable"])
async def test_anthropic_catalog_fails_closed_without_api_key(self):
with patch.dict(provider_registry.os.environ, {}, clear=True):
result = await provider_registry.discover_capabilities(
"claude_api", force=True
)
self.assertFalse(result.available)
self.assertEqual(result.source, "unavailable")
self.assertEqual(result.models, [])
self.assertIn("ANTHROPIC_API_KEY", result.detail)
async def test_codex_generation_uses_model_and_reasoning_from_selection(self):
capabilities = provider_registry.EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[
provider_registry.EngineModelOption(
id="gpt-5.6-terra",
label="GPT-5.6-Terra",
reasoning_efforts=["low", "medium", "high"],
default_reasoning_effort="medium",
is_default=True,
)
],
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
fetched_at=1,
)
stdout = "\n".join(
[
json.dumps(
{
"type": "item.completed",
"item": {"type": "agent_message", "text": "OK"},
}
),
json.dumps(
{
"type": "turn.completed",
"usage": {"input_tokens": 12, "output_tokens": 3},
}
),
]
)
runner = AsyncMock(return_value=(stdout, ""))
request = GenerateRequest(
provider="codex_cli",
model="gpt-5.6-terra",
reasoning_effort="medium",
messages=[EngineMessage(role="user", content="hello")],
)
with (
patch.object(provider_registry, "_binary", return_value="codex.exe"),
patch.object(
provider_registry,
"discover_capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(provider_registry, "_run_process", runner),
):
result = await provider_registry.generate_with_provider(
request,
system_prompt="system",
user_payload="hello",
)
self.assertEqual(result.text, "OK")
self.assertEqual(result.tokens_in, 12)
self.assertEqual(result.tokens_out, 3)
args = runner.await_args.args[0]
self.assertIn("gpt-5.6-terra", args)
self.assertIn('model_reasoning_effort="medium"', args)
self.assertEqual(args[-1], "-")
self.assertIn("[시스템 지침]", runner.await_args.kwargs["input_text"])
async def test_agy_generation_passes_prompt_immediately_after_print_flag(self):
capabilities = provider_registry.EngineCapabilitiesResponse(
provider="agy_cli",
available=True,
source="live_cli",
models=[
provider_registry.EngineModelOption(
id="gemini-3.6-flash-high",
label="Gemini 3.6 Flash (High)",
reasoning_efforts=["high"],
default_reasoning_effort="high",
)
],
default_model="gemini-3.6-flash-high",
default_reasoning_effort="high",
fetched_at=1,
)
request = GenerateRequest(
provider="agy_cli",
model="gemini-3.6-flash-high",
reasoning_effort="high",
messages=[EngineMessage(role="user", content="hello")],
)
runner = AsyncMock(return_value=("OK\n", ""))
with (
patch.object(provider_registry, "_binary", return_value="agy"),
patch.object(
provider_registry,
"discover_capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(provider_registry, "_run_process", runner),
):
result = await provider_registry.generate_with_provider(
request,
system_prompt="system",
user_payload="hello",
)
self.assertEqual(result.text, "OK")
args = runner.await_args.args[0]
print_index = args.index("--print")
self.assertEqual(print_index, len(args) - 2)
self.assertIn("[시스템 지침]", args[-1])
self.assertNotIn("input_text", runner.await_args.kwargs)
async def test_agy_stream_forwards_live_deltas_without_repeating_final_response(self):
capabilities = provider_registry.EngineCapabilitiesResponse(
provider="agy_cli",
available=True,
source="live_cli",
models=[
provider_registry.EngineModelOption(
id="gemini-3.6-flash-high",
label="Gemini 3.6 Flash (High)",
reasoning_efforts=["high"],
default_reasoning_effort="high",
is_default=True,
)
],
default_model="gemini-3.6-flash-high",
default_reasoning_effort="high",
fetched_at=1,
)
request = GenerateRequest(
provider="agy_cli",
model="gemini-3.6-flash-high",
reasoning_effort="high",
messages=[EngineMessage(role="user", content="hello")],
)
process = _FakeAgyProcess(
[
{
"event": "step_update",
"step_update": {
"step_type": "agent_response",
"state": "ACTIVE",
"text_delta": "",
},
},
{
"event": "step_update",
"step_update": {
"step_type": "agent_response",
"state": "DONE",
"text_delta": "",
},
},
{
"event": "result",
"result": {
"status": "SUCCESS",
"response": "안녕",
"usage": {"input_tokens": 12, "output_tokens": 2},
},
},
]
)
captured: list[tuple] = []
async def fake_create_subprocess_exec(*args, **kwargs):
captured.append(args)
return process
with (
patch.object(provider_registry, "_binary", return_value="agy.exe"),
patch.object(
provider_registry,
"discover_capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(
provider_registry.asyncio,
"create_subprocess_exec",
fake_create_subprocess_exec,
),
):
events = [
event
async for event in provider_registry.stream_with_provider(
request,
system_prompt="system",
user_payload="hello",
)
]
self.assertEqual([event.type for event in events], ["delta", "delta", "done"])
self.assertEqual("".join(event.text for event in events), "안녕")
self.assertEqual(events[-1].result.text, "안녕")
self.assertEqual(events[-1].result.tokens_in, 12)
self.assertEqual(events[-1].result.tokens_out, 2)
args = captured[0]
self.assertIn("--output-format", args)
self.assertEqual(args[args.index("--output-format") + 1], "stream-json")
self.assertEqual(args.index("--print"), len(args) - 2)
async def test_generation_rejects_model_effort_not_returned_by_provider(self):
capabilities = provider_registry.EngineCapabilitiesResponse(
provider="agy_cli",
available=True,
source="live_cli",
models=[
provider_registry.EngineModelOption(
id="gemini-3.6-flash-high",
label="Gemini 3.6 Flash (High)",
reasoning_efforts=["high"],
default_reasoning_effort="high",
)
],
default_model="gemini-3.6-flash-high",
default_reasoning_effort="high",
fetched_at=1,
)
request = GenerateRequest(
provider="agy_cli",
model="gemini-3.6-flash-high",
reasoning_effort="low",
messages=[EngineMessage(role="user", content="hello")],
)
with patch.object(
provider_registry,
"discover_capabilities",
AsyncMock(return_value=capabilities),
):
with self.assertRaisesRegex(
provider_registry.ProviderError, "사용할 수 없는 추론 강도"
):
await provider_registry._resolve_selection(request, "agy_cli")
if __name__ == "__main__":
unittest.main()