feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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