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

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

View file

@ -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)