G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -11,12 +11,14 @@ Vignette 엔진 게이트웨이 — 로컬 claude -p(Opus 4.8) 상주 멀티턴
import asyncio
import json
import os
import secrets
import time
import uuid
from collections import deque
from dataclasses import dataclass
from typing import Any, Optional
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
@ -58,6 +60,28 @@ SESSION_IDLE_TTL_SECONDS = float(os.environ.get("ENGINE_SESSION_IDLE_TTL_SECONDS
MAX_RESIDENT_SESSIONS = max(1, int(os.environ.get("ENGINE_MAX_RESIDENT_SESSIONS", "24")))
GATEWAY_PROVIDER = "claude_cli"
GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
# claude -p 자식의 stderr 보존량. 인증 만료/플래그 오류처럼 CLI가 stdout 한 줄도 못 내고
# 죽는 경우 원인은 stderr에만 남는다. 이걸 버리면 게이트웨이는 "empty engine response"만
# 보고하고 운영자는 프로세스 환경을 재현해야 원인을 안다(2026-08-07 공개 런타임 사고).
STDERR_TAIL_LINES = 8
STDERR_TAIL_CHARS = 400
ENGINE_TOKEN_HEADER = "X-Vignette-Engine-Token"
_SECRET_PLACEHOLDER_PREFIXES = ("change-me", "replace-with", "dummy", "example")
def _load_gateway_shared_secret() -> str:
value = os.environ.get("ENGINE_GATEWAY_SHARED_SECRET", "").strip()
if not value:
return ""
if len(value) < 32 or value.lower().startswith(_SECRET_PLACEHOLDER_PREFIXES):
raise RuntimeError(
"ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value "
"containing at least 32 characters"
)
return value
ENGINE_GATEWAY_SHARED_SECRET = _load_gateway_shared_secret()
@dataclass(frozen=True, slots=True)
@ -83,6 +107,54 @@ BASE_ARGS = [
"--exclude-dynamic-system-prompt-sections",
]
def _usage_int(payload: dict[str, Any], *keys: str) -> int:
for key in keys:
value = payload.get(key)
if value is None:
continue
try:
return max(0, int(value))
except (TypeError, ValueError):
continue
return 0
def _claude_result_tokens(result: dict[str, Any]) -> tuple[int, int]:
"""Claude CLI result의 전체 agent-tree 토큰을 기존 원장 두 컬럼으로 정규화한다.
modelUsage/model_usage가 있으면 서브에이전트를 포함한 모델별 사용량을 합산한다.
입력 토큰은 공식 계약에 맞춰 비캐시 입력 + cache read + cache creation이다.
"""
raw_model_usage = result.get("modelUsage") or result.get("model_usage")
usage_rows = (
[row for row in raw_model_usage.values() if isinstance(row, dict)]
if isinstance(raw_model_usage, dict)
else []
)
if not usage_rows:
usage = result.get("usage")
usage_rows = [usage] if isinstance(usage, dict) else []
tokens_in = 0
tokens_out = 0
for usage in usage_rows:
tokens_in += _usage_int(usage, "inputTokens", "input_tokens")
tokens_in += _usage_int(
usage,
"cacheReadInputTokens",
"cache_read_input_tokens",
)
tokens_in += _usage_int(
usage,
"cacheCreationInputTokens",
"cache_creation_input_tokens",
)
tokens_out += _usage_int(usage, "outputTokens", "output_tokens")
return tokens_in, tokens_out
class EngineSession:
"""claude -p 상주 프로세스 1개 = 상담 회기 1개."""
@ -103,6 +175,55 @@ class EngineSession:
self.cost_usd = 0.0
self.turns = 0
self.last_used_at = time.monotonic()
# stderr는 계속 비워야 한다. PIPE를 열고 아무도 읽지 않으면 자식이 파이프 버퍼가
# 찬 시점에 블록된다. 겸사겸사 마지막 몇 줄을 진단용으로 남긴다.
self._stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
self._stderr_task: asyncio.Task | None = None
async def _drain_stderr(self) -> None:
proc = self.proc
if proc is None or proc.stderr is None:
return
try:
while True:
line = await proc.stderr.readline()
if not line:
return
text = line.decode("utf-8", "replace").strip()
if text:
self._stderr_tail.append(text)
except asyncio.CancelledError:
raise
except Exception:
return
def stderr_tail(self) -> str:
"""자식 CLI가 마지막으로 남긴 stderr 요약(진단 표면용)."""
joined = " | ".join(self._stderr_tail)
if len(joined) > STDERR_TAIL_CHARS:
return "" + joined[-STDERR_TAIL_CHARS:]
return joined
async def failure_detail(self, fallback: str) -> str:
"""텍스트를 못 받았을 때 원인을 최대한 좁힌 detail을 만든다."""
task = self._stderr_task
if task is not None and not task.done():
# 자식이 즉시 죽은 경우 stdout EOF가 stderr 드레인보다 먼저 도착할 수 있다.
# shield로 감싸 세션이 계속 살아있는 경우의 드레인을 취소하지 않는다.
try:
await asyncio.wait_for(asyncio.shield(task), timeout=0.5)
except Exception:
pass
parts: list[str] = []
code = self.proc.returncode if self.proc is not None else None
if code is not None:
parts.append(f"exit={code}")
tail = self.stderr_tail()
if tail:
parts.append(tail)
if not parts:
return fallback
return f"{fallback} ({' · '.join(parts)})"
async def start(self) -> None:
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
@ -123,6 +244,7 @@ class EngineSession:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._stderr_task = asyncio.create_task(self._drain_stderr())
async def turn(self, content: str, timeout: float = 120.0) -> dict:
if self.proc is None or self.proc.returncode is not None:
@ -158,17 +280,28 @@ class EngineSession:
result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
self.last_used_at = time.monotonic()
self.cost_usd = result.get("total_cost_usd", self.cost_usd)
tokens_in, tokens_out = _claude_result_tokens(result)
self.turns += 1
assistant_text = "".join(text_parts)
terminal_result = result.get("result")
terminal_text = terminal_result if isinstance(terminal_result, str) else ""
# Claude CLI stream-json can emit the complete successful answer only
# on the terminal result event. Prefer assistant events when present
# (they preserve streaming semantics), but never discard a terminal-only
# structured response.
response_text = assistant_text or terminal_text
error_detail = (
result.get("error")
or result.get("result")
or (result.get("errors") or [None])[0]
or "".join(text_parts)
or response_text
or None
)
return {
"text": "".join(text_parts),
"text": response_text,
"cost_usd": self.cost_usd,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"turns": self.turns,
"is_error": result.get("is_error", False),
"error": error_detail,
@ -237,7 +370,15 @@ class EngineSession:
elif t == "result":
self.last_used_at = time.monotonic()
self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
tokens_in, tokens_out = _claude_result_tokens(obj)
self.turns += 1
terminal_result = obj.get("result")
terminal_text = (
terminal_result if isinstance(terminal_result, str) else ""
)
if not emitted and terminal_text:
emitted = terminal_text
yield {"type": "delta", "text": terminal_text}
error_detail = (
obj.get("error")
or obj.get("result")
@ -249,6 +390,8 @@ class EngineSession:
"type": "done",
"text": emitted,
"cost_usd": self.cost_usd,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"turns": self.turns,
"is_error": obj.get("is_error", False),
"error": error_detail,
@ -265,6 +408,14 @@ class EngineSession:
await asyncio.wait_for(self.proc.wait(), timeout=5)
except Exception:
self.proc.kill()
task = self._stderr_task
self._stderr_task = None
if task is not None and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
SESSIONS: dict[str, EngineSession] = {}
@ -272,6 +423,21 @@ _SESSION_RESOLVE_LOCK = asyncio.Lock()
_READY_CACHE: dict[tuple[str, str, str], dict[str, Any]] = {}
_READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway")
_AUTH_EXEMPT_PATHS = frozenset({"/health"})
@app.middleware("http")
async def require_gateway_shared_secret(request: Request, call_next):
"""설정된 인스턴스는 순수 liveness 외 모든 HTTP 경로를 fail-closed로 막는다."""
expected = ENGINE_GATEWAY_SHARED_SECRET
if expected and request.url.path not in _AUTH_EXEMPT_PATHS:
provided = request.headers.get(ENGINE_TOKEN_HEADER, "")
if not secrets.compare_digest(provided, expected):
return JSONResponse(
{"detail": "invalid engine gateway credentials"},
status_code=401,
)
return await call_next(request)
class CreateReq(BaseModel):
@ -379,13 +545,19 @@ async def ready(
"Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS
)
if result.get("is_error"):
detail = str(result.get("error") or "engine returned an error")
detail = await probe.failure_detail(
str(result.get("error") or "engine returned an error")
)
else:
text = str(result.get("text") or "").strip()
ok = bool(text)
detail = text or "empty engine response"
detail = text or await probe.failure_detail(
"empty engine response"
)
except Exception as exc:
detail = str(exc)
# TimeoutError 등 str()이 비는 예외가 있어 타입명을 최소 보장한다.
reason = str(exc).strip() or type(exc).__name__
detail = await probe.failure_detail(reason)
finally:
await probe.close()
else:
@ -667,8 +839,8 @@ async def v1_generate(req: GwGenerateReq):
text=text,
model=_response_model_name(s),
provider=GATEWAY_PROVIDER,
tokens_in=0,
tokens_out=0,
tokens_in=result.get("tokens_in", 0),
tokens_out=result.get("tokens_out", 0),
cost_usd=result.get("cost_usd", 0.0),
inference_geo="us",
structured=structured,
@ -747,8 +919,8 @@ async def v1_stream(req: GwGenerateReq):
StreamDoneEvent(
provider=GATEWAY_PROVIDER,
model=_response_model_name(s),
tokens_in=0,
tokens_out=0,
tokens_in=evt.get("tokens_in", 0),
tokens_out=evt.get("tokens_out", 0),
cost_usd=evt.get("cost_usd", 0.0),
turns=evt.get("turns", 0),
),

View file

@ -19,7 +19,6 @@ 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,
@ -29,6 +28,7 @@ from app.contracts.engine_gateway import (
ReasoningEffort,
normalize_engine_gateway_model,
)
from app.services.llm_pricing import estimate_reference_cost
CODEX_DEFAULT_MODEL, CODEX_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS["codex_cli"]
AGY_DEFAULT_MODEL, AGY_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS["agy_cli"]
@ -580,6 +580,7 @@ async def _generate_codex(
text = ""
tokens_in = 0
tokens_out = 0
cached_input_tokens = 0
for line in stdout.splitlines():
try:
event = json.loads(line)
@ -593,16 +594,25 @@ async def _generate_codex(
usage = event.get("usage") or {}
tokens_in = int(usage.get("input_tokens") or 0)
tokens_out = int(usage.get("output_tokens") or 0)
cached_input_tokens = int(usage.get("cached_input_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가 최종 응답을 반환하지 않았습니다.")
estimate = estimate_reference_cost(
provider="codex_cli",
model=model,
tokens_in=tokens_in,
tokens_out=tokens_out,
cached_input_tokens=cached_input_tokens,
)
return ProviderGenerateResult(
text=text,
model=model,
provider="codex_cli",
tokens_in=tokens_in,
tokens_out=tokens_out,
cost_usd=estimate.cost_usd if estimate is not None else 0.0,
structured=_structured_or_none(text, req),
)
@ -610,32 +620,12 @@ async def _generate_codex(
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),
)
# text 출력은 토큰 사용량을 주지 않는다. stream-json의 terminal result를
# 동일하게 소비해 generate와 stream 모두 같은 token/cost 계약을 유지한다.
async for event in _stream_agy(req, system_prompt, user_payload):
if event.type == "done" and event.result is not None:
return event.result
raise ProviderError("Agy CLI가 최종 응답을 반환하지 않았습니다.")
async def _stream_agy(
@ -680,6 +670,7 @@ async def _stream_agy(
final_text = ""
tokens_in = 0
tokens_out = 0
cached_input_tokens = 0
result_status = ""
try:
async with asyncio.timeout(CLI_TIMEOUT_SECONDS):
@ -705,6 +696,11 @@ async def _stream_agy(
usage = result.get("usage") or {}
tokens_in = int(usage.get("input_tokens") or 0)
tokens_out = int(usage.get("output_tokens") or 0)
cached_input_tokens = int(
usage.get("cache_read_tokens")
or usage.get("cached_input_tokens")
or 0
)
returncode = await proc.wait()
except TimeoutError as exc:
raise ProviderError(
@ -731,6 +727,13 @@ async def _stream_agy(
elif not emitted:
emitted = resolved_text
yield ProviderStreamEvent(type="delta", text=resolved_text)
estimate = estimate_reference_cost(
provider="agy_cli",
model=model,
tokens_in=tokens_in,
tokens_out=tokens_out,
cached_input_tokens=cached_input_tokens,
)
yield ProviderStreamEvent(
type="done",
result=ProviderGenerateResult(
@ -739,6 +742,7 @@ async def _stream_agy(
provider="agy_cli",
tokens_in=tokens_in,
tokens_out=tokens_out,
cost_usd=estimate.cost_usd if estimate is not None else 0.0,
structured=_structured_or_none(resolved_text, req),
),
)
@ -789,12 +793,22 @@ async def _generate_claude_api(
raise ProviderError("Anthropic Messages API가 텍스트 응답을 반환하지 않았습니다.")
usage = body.get("usage") or {}
inference_geo = body.get("inference_geo")
tokens_in = int(usage.get("input_tokens") or 0)
tokens_out = int(usage.get("output_tokens") or 0)
estimate = estimate_reference_cost(
provider="claude_api",
model=str(body.get("model") or model),
tokens_in=tokens_in,
tokens_out=tokens_out,
cached_input_tokens=int(usage.get("cache_read_input_tokens") or 0),
)
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),
tokens_in=tokens_in,
tokens_out=tokens_out,
cost_usd=estimate.cost_usd if estimate is not None else 0.0,
inference_geo=str(inference_geo) if inference_geo else None,
structured=_structured_or_none(text, req),
)

View file

@ -1 +1,2 @@
fastapi\nuvicorn[standard]
fastapi==0.111.0
uvicorn[standard]==0.30.6

View file

@ -1,5 +1,6 @@
import asyncio
import json
import secrets
import shutil
import subprocess
import unittest
@ -8,6 +9,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from jsonschema import Draft202012Validator
from fastapi.testclient import TestClient
from app import engine_client
from app.contracts import engine_gateway as contract
@ -195,6 +197,106 @@ class _FakeStreamSession:
self.closed = True
class GatewayAuthenticationTest(unittest.TestCase):
SECRET = "engine-gateway-test-secret-" + ("x" * 32)
def test_unset_secret_preserves_local_gateway_compatibility(self):
with patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", ""):
response = TestClient(gateway.app).delete("/session/not-running")
self.assertEqual(response.status_code, 200)
def test_configured_secret_rejects_missing_and_wrong_credentials(self):
with patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", self.SECRET):
client = TestClient(gateway.app)
missing = client.post("/v1/generate", json={})
wrong = client.post(
"/v1/generate",
json={},
headers={gateway.ENGINE_TOKEN_HEADER: "wrong"},
)
authenticated = client.post(
"/v1/generate",
json={},
headers={gateway.ENGINE_TOKEN_HEADER: self.SECRET},
)
self.assertEqual(missing.status_code, 401)
self.assertEqual(wrong.status_code, 401)
self.assertEqual(authenticated.status_code, 422)
def test_health_probe_remains_unauthenticated(self):
with patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", self.SECRET):
response = TestClient(gateway.app).get("/health")
self.assertEqual(response.status_code, 200)
def test_ready_probe_requires_credentials_because_it_runs_generation(self):
with patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", self.SECRET):
response = TestClient(gateway.app).get("/ready")
self.assertEqual(response.status_code, 401)
def test_gateway_rejects_weak_configured_secret_at_startup(self):
for value in ("too-short", "example-gateway-secret-with-32-characters"):
with (
self.subTest(value=value),
patch.dict(
gateway.os.environ,
{"ENGINE_GATEWAY_SHARED_SECRET": value},
),
self.assertRaises(RuntimeError),
):
gateway._load_gateway_shared_secret()
def test_invalid_token_uses_constant_time_comparison(self):
with (
patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", self.SECRET),
patch.object(
gateway.secrets,
"compare_digest",
wraps=secrets.compare_digest,
) as compare_digest,
):
response = TestClient(gateway.app).get(
"/v1/capabilities",
headers={gateway.ENGINE_TOKEN_HEADER: "wrong"},
)
self.assertEqual(response.status_code, 401)
compare_digest.assert_called_once_with("wrong", self.SECRET)
def test_openapi_schema_does_not_expose_secret_or_auth_header(self):
with patch.object(gateway, "ENGINE_GATEWAY_SHARED_SECRET", self.SECRET):
schema = json.dumps(gateway.app.openapi())
self.assertNotIn(self.SECRET, schema)
self.assertNotIn(gateway.ENGINE_TOKEN_HEADER, schema)
def test_engine_client_adds_gateway_token_to_default_headers(self):
with patch.object(engine_client.httpx, "AsyncClient") as async_client_cls:
client = engine_client.EngineClient(
"http://127.0.0.1:9099",
shared_secret=self.SECRET,
)
client._new_client()
self.assertEqual(
async_client_cls.call_args.kwargs["headers"],
{gateway.ENGINE_TOKEN_HEADER: self.SECRET},
)
def test_engine_client_omits_gateway_token_when_unset(self):
with patch.object(engine_client.httpx, "AsyncClient") as async_client_cls:
client = engine_client.EngineClient(
"http://127.0.0.1:9099",
shared_secret="",
)
client._new_client()
self.assertEqual(async_client_cls.call_args.kwargs["headers"], {})
class GatewayModelTest(unittest.TestCase):
def test_contract_owns_gateway_default_model_sentinel(self):
self.assertEqual(contract.ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "gateway-default")
@ -456,6 +558,35 @@ class GatewayModelTest(unittest.TestCase):
{"reply": "embedded"},
)
def test_generate_response_structured_payload_repairs_only_trailing_commas(self):
response = contract.GenerateResponse(
text=(
'```json\n'
'{"reply":"keep literal , } and escaped \\\" text",'
'"items":[{"value":1,},],}\n'
'```'
),
provider="test",
model="test-model",
)
self.assertEqual(
contract.structured_payload_from_response(response),
{
"reply": 'keep literal , } and escaped " text',
"items": [{"value": 1}],
},
)
def test_generate_response_structured_payload_does_not_repair_other_corruption(self):
response = contract.GenerateResponse(
text='{"reply":"missing separator" "items":[]}',
provider="test",
model="test-model",
)
self.assertIsNone(contract.structured_payload_from_response(response))
def test_generate_response_structured_payload_rejects_non_object_json(self):
response = contract.GenerateResponse(
text='["not", "object"]',
@ -610,7 +741,25 @@ class GatewayModelTest(unittest.TestCase):
"type": "assistant",
"message": {"content": [{"type": "text", "text": "안녕!"}]},
},
{"type": "result", "is_error": False, "total_cost_usd": 0.01},
{
"type": "result",
"is_error": False,
"total_cost_usd": 0.01,
"modelUsage": {
"claude-opus-4-8": {
"inputTokens": 12,
"outputTokens": 7,
"cacheReadInputTokens": 101,
"cacheCreationInputTokens": 23,
},
"claude-haiku-4-5": {
"inputTokens": 3,
"outputTokens": 2,
"cacheReadInputTokens": 9,
"cacheCreationInputTokens": 0,
},
},
},
]
)
session = gateway.EngineSession()
@ -630,6 +779,8 @@ class GatewayModelTest(unittest.TestCase):
"type": "done",
"text": "안녕!",
"cost_usd": 0.01,
"tokens_in": 148,
"tokens_out": 9,
"turns": 1,
"is_error": False,
"error": "안녕!",
@ -637,6 +788,66 @@ class GatewayModelTest(unittest.TestCase):
],
)
def test_engine_session_uses_terminal_result_when_assistant_event_is_absent(self):
process = _FakeProcess()
process.stdin = _StreamStdin()
process.stdout = _StreamStdout(
[
{
"type": "result",
"is_error": False,
"result": '{"goal":{"score":0.2}}',
"usage": {"input_tokens": 10, "output_tokens": 5},
}
]
)
session = gateway.EngineSession()
session.proc = process
result = asyncio.run(session.turn("평가"))
self.assertEqual(result["text"], '{"goal":{"score":0.2}}')
self.assertFalse(result["is_error"])
def test_engine_session_streams_terminal_result_when_assistant_event_is_absent(self):
process = _FakeProcess()
process.stdin = _StreamStdin()
process.stdout = _StreamStdout(
[
{
"type": "result",
"is_error": False,
"result": "terminal-only",
"usage": {"input_tokens": 10, "output_tokens": 2},
}
]
)
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[0], {"type": "delta", "text": "terminal-only"})
self.assertEqual(events[-1]["text"], "terminal-only")
def test_claude_result_tokens_falls_back_to_top_level_usage(self):
self.assertEqual(
gateway._claude_result_tokens(
{
"usage": {
"input_tokens": 17,
"output_tokens": 5,
"cache_read_input_tokens": 200,
"cache_creation_input_tokens": 30,
}
}
),
(247, 5),
)
def test_v1_generate_routes_non_claude_provider_through_registry(self):
result = SimpleNamespace(
text="registry response",
@ -770,7 +981,13 @@ class GatewayModelTest(unittest.TestCase):
async def fake_turn(content, timeout=120.0):
calls.append((content, timeout))
return {"text": "reused response", "cost_usd": 0.01, "is_error": False}
return {
"text": "reused response",
"cost_usd": 0.01,
"tokens_in": 321,
"tokens_out": 12,
"is_error": False,
}
async def fake_close():
closes.append(True)
@ -784,6 +1001,8 @@ 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(validated.tokens_in, 321)
self.assertEqual(validated.tokens_out, 12)
self.assertEqual(
calls,
[("[이번 상담자 발화]\nhello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)],
@ -801,7 +1020,13 @@ class GatewayModelTest(unittest.TestCase):
async def fake_turn(self, content, timeout=120.0):
turned.append((self, content, timeout))
return {"text": "fresh response", "cost_usd": 0.02, "is_error": False}
return {
"text": "fresh response",
"cost_usd": 0.02,
"tokens_in": 654,
"tokens_out": 21,
"is_error": False,
}
async def fake_close(self):
closed.append(self)
@ -817,6 +1042,8 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(validated.text, "fresh response")
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.02)
self.assertEqual(validated.tokens_in, 654)
self.assertEqual(validated.tokens_out, 21)
self.assertEqual(len(started), 1)
self.assertEqual(
turned,
@ -850,7 +1077,13 @@ class GatewayModelTest(unittest.TestCase):
session = _FakeStreamSession(
[
{"type": "delta", "text": "안녕"},
{"type": "done", "cost_usd": 0.03, "turns": 2},
{
"type": "done",
"cost_usd": 0.03,
"tokens_in": 456,
"tokens_out": 18,
"turns": 2,
},
],
model="stream-model",
)
@ -868,6 +1101,8 @@ class GatewayModelTest(unittest.TestCase):
self.assertIn('"provider": "claude_cli"', body)
self.assertIn('"model": "stream-model"', body)
self.assertIn('"cost_usd": 0.03', body)
self.assertIn('"tokens_in": 456', body)
self.assertIn('"tokens_out": 18', body)
self.assertEqual(session.content, "[이번 상담자 발화]\nhello")
self.assertEqual(session.timeout, 600.0)
self.assertTrue(session.closed)

View file

@ -166,7 +166,11 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
json.dumps(
{
"type": "turn.completed",
"usage": {"input_tokens": 12, "output_tokens": 3},
"usage": {
"input_tokens": 12,
"cached_input_tokens": 2,
"output_tokens": 3,
},
}
),
]
@ -196,13 +200,14 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.text, "OK")
self.assertEqual(result.tokens_in, 12)
self.assertEqual(result.tokens_out, 3)
self.assertEqual(result.cost_usd, 0.0000705)
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):
async def test_agy_generation_uses_stream_json_usage_and_reference_cost(self):
capabilities = provider_registry.EngineCapabilitiesResponse(
provider="agy_cli",
available=True,
@ -225,7 +230,35 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
reasoning_effort="high",
messages=[EngineMessage(role="user", content="hello")],
)
runner = AsyncMock(return_value=("OK\n", ""))
process = _FakeAgyProcess(
[
{
"event": "step_update",
"step_update": {
"step_type": "agent_response",
"state": "DONE",
"text_delta": "OK",
},
},
{
"event": "result",
"result": {
"status": "SUCCESS",
"response": "OK",
"usage": {
"input_tokens": 12,
"cache_read_tokens": 2,
"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"),
@ -234,7 +267,11 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
"discover_capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(provider_registry, "_run_process", runner),
patch.object(
provider_registry.asyncio,
"create_subprocess_exec",
fake_create_subprocess_exec,
),
):
result = await provider_registry.generate_with_provider(
request,
@ -243,11 +280,14 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(result.text, "OK")
args = runner.await_args.args[0]
self.assertEqual(result.tokens_in, 12)
self.assertEqual(result.tokens_out, 2)
self.assertEqual(result.cost_usd, 0.0000303)
args = captured[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)
self.assertEqual(args[args.index("--output-format") + 1], "stream-json")
async def test_agy_stream_forwards_live_deltas_without_repeating_final_response(self):
capabilities = provider_registry.EngineCapabilitiesResponse(
@ -296,7 +336,11 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
"result": {
"status": "SUCCESS",
"response": "안녕",
"usage": {"input_tokens": 12, "output_tokens": 2},
"usage": {
"input_tokens": 12,
"cache_read_tokens": 2,
"output_tokens": 2,
},
},
},
]
@ -334,6 +378,7 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(events[-1].result.text, "안녕")
self.assertEqual(events[-1].result.tokens_in, 12)
self.assertEqual(events[-1].result.tokens_out, 2)
self.assertEqual(events[-1].result.cost_usd, 0.0000303)
args = captured[0]
self.assertIn("--output-format", args)
self.assertEqual(args[args.index("--output-format") + 1], "stream-json")