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