음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -13,11 +13,24 @@ import json
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Literal, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.contracts.engine_gateway import (
|
||||
AIRole,
|
||||
ENGINE_GATEWAY_SSE_DONE,
|
||||
ENGINE_GATEWAY_SSE_ERROR,
|
||||
ENGINE_GATEWAY_SSE_TOKEN,
|
||||
EngineMessage as GwMessage,
|
||||
GenerateRequest as GwGenerateReq,
|
||||
StreamDoneEvent,
|
||||
StreamErrorEvent,
|
||||
StreamTokenEvent,
|
||||
sse_frame,
|
||||
)
|
||||
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
||||
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
|
||||
|
|
@ -321,26 +334,6 @@ async def close_session(sid: str):
|
|||
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
AIRole = Literal["client", "counselor", "evaluator"]
|
||||
|
||||
|
||||
class GwMessage(BaseModel):
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
cache: bool = False # 프롬프트 캐싱 힌트 (L0~L2 cache_control 대상)
|
||||
|
||||
|
||||
class GwGenerateReq(BaseModel):
|
||||
ai_role: AIRole = "client"
|
||||
messages: list[GwMessage]
|
||||
model: Optional[str] = None
|
||||
max_tokens: int = 1024
|
||||
temperature: float = 0.7
|
||||
structured_schema: Optional[dict[str, Any]] = None
|
||||
session_id: Optional[str] = None # 풀 재사용 키(있으면 멀티턴 캐시)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _split_messages(messages: list[GwMessage]) -> tuple[str, str]:
|
||||
"""EngineMessage[] → (system_prompt, user_payload).
|
||||
|
||||
|
|
@ -452,28 +445,33 @@ async def v1_stream(req: GwGenerateReq):
|
|||
try:
|
||||
async for evt in s.turn_stream(user_payload, timeout=600.0):
|
||||
if evt.get("type") == "delta":
|
||||
payload = json.dumps({"text": evt["text"]}, ensure_ascii=False)
|
||||
yield f"event: token\ndata: {payload}\n\n"
|
||||
yield sse_frame(
|
||||
ENGINE_GATEWAY_SSE_TOKEN,
|
||||
StreamTokenEvent(text=evt["text"]),
|
||||
)
|
||||
elif evt.get("type") == "done":
|
||||
if evt.get("is_error"):
|
||||
err = json.dumps({"detail": str(evt.get("error"))}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {err}\n\n"
|
||||
else:
|
||||
meta = json.dumps(
|
||||
{
|
||||
"provider": "claude_cli",
|
||||
"model": s.model or DEFAULT_MODEL or "claude-opus-4-8",
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"cost_usd": evt.get("cost_usd", 0.0),
|
||||
"turns": evt.get("turns", 0),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
yield sse_frame(
|
||||
ENGINE_GATEWAY_SSE_ERROR,
|
||||
StreamErrorEvent(detail=str(evt.get("error"))),
|
||||
)
|
||||
else:
|
||||
yield sse_frame(
|
||||
ENGINE_GATEWAY_SSE_DONE,
|
||||
StreamDoneEvent(
|
||||
provider="claude_cli",
|
||||
model=s.model or DEFAULT_MODEL or "claude-opus-4-8",
|
||||
tokens_in=0,
|
||||
tokens_out=0,
|
||||
cost_usd=evt.get("cost_usd", 0.0),
|
||||
turns=evt.get("turns", 0),
|
||||
),
|
||||
)
|
||||
yield f"event: done\ndata: {meta}\n\n"
|
||||
except Exception as e: # 전송 도중 실패도 SSE 프레임으로 알린다
|
||||
err = json.dumps({"detail": str(e)}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {err}\n\n"
|
||||
yield sse_frame(
|
||||
ENGINE_GATEWAY_SSE_ERROR,
|
||||
StreamErrorEvent(detail=str(e)),
|
||||
)
|
||||
finally:
|
||||
if ephemeral:
|
||||
await s.close()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import asyncio
|
|||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import engine_client
|
||||
from app.contracts import engine_gateway as contract
|
||||
from engine_gateway import gateway
|
||||
|
||||
|
||||
|
|
@ -86,6 +88,22 @@ class GatewayModelTest(unittest.TestCase):
|
|||
def tearDown(self):
|
||||
gateway.SESSIONS.clear()
|
||||
|
||||
def test_gateway_reuses_shared_engine_contract_models(self):
|
||||
self.assertIs(gateway.GwGenerateReq, contract.GenerateRequest)
|
||||
self.assertIs(gateway.GwMessage, contract.EngineMessage)
|
||||
self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest)
|
||||
self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error"))
|
||||
|
||||
def test_sse_frame_helper_preserves_gateway_wire_contract(self):
|
||||
self.assertEqual(
|
||||
contract.sse_frame("token", contract.StreamTokenEvent(text="hello")),
|
||||
'event: token\ndata: {"text": "hello"}\n\n',
|
||||
)
|
||||
self.assertEqual(
|
||||
contract.sse_frame("error", contract.StreamErrorEvent(detail="failed")),
|
||||
'event: error\ndata: {"detail": "failed"}\n\n',
|
||||
)
|
||||
|
||||
def test_resolve_session_uses_request_model_for_claude_cli(self):
|
||||
captured, process_patch = _capture_subprocess()
|
||||
with (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue