feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__ = [
|
||||
|
|
|
|||
|
|
@ -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는 키가 없으면 사용할 수 없음으로 표시한다.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
836
apps/api/engine_gateway/provider_registry.py
Normal file
836
apps/api/engine_gateway/provider_registry.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
377
apps/api/engine_gateway/test_provider_registry.py
Normal file
377
apps/api/engine_gateway/test_provider_registry.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue