Files
hunjing/backend/app/shenshi_client/client.py
T

94 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""神识客户端:灵台唯一的模型出口边界。
两种实现可互换(Protocol):
- ``HttpShenshiClient``:经 HTTP+SSE 调用 Go 神识服务(SHENSHI_BASE_URL 配置时启用);
- ``EchoShenshi``:进程内回声桩,逐字回流输入,供本地开发与测试在无神识时跑通端到端。
领域模块禁止绕过本边界 import Provider SDK(docs/architecture/service-boundaries.md §4)。
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Protocol
import httpx
from app.shenshi_client.types import ShenshiChatRequest, ShenshiDelta, ShenshiError
class ShenshiClient(Protocol):
"""神识数据面契约:流式对话补全。"""
def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]: ...
class EchoShenshi:
"""进程内回声桩:把最后一条用户消息按固定粒度切分回流。
只用于 M0 骨架验证(登录 → 占位 Run → SSE 端到端),不模拟任何 Provider 行为。
"""
chunk_size = 8
async def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]:
prompt = next((m.content for m in reversed(request.messages) if m.role == "user"), "")
text = f"【回声桩】{prompt}"
for i in range(0, len(text), self.chunk_size):
yield ShenshiDelta(delta=text[i : i + self.chunk_size])
yield ShenshiDelta(
finish_reason="stop",
usage_prompt_tokens=max(len(prompt) // 4, 1),
usage_completion_tokens=max(len(text) // 4, 1),
)
class HttpShenshiClient:
"""HTTP+SSE 神识客户端。
认证:服务令牌经 ``Authorization: Bearer`` 透传(管理面 RBAC 不在此层)。
流解析:``data:`` 帧为 ShenshiDelta JSON,``[DONE]`` 结束;非 2xx 映射 ShenshiError。
"""
def __init__(self, base_url: str, service_token: str, timeout: float = 120.0) -> None:
self._base_url = base_url.rstrip("/")
self._headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
self._timeout = timeout
async def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]:
payload = request.model_dump(mode="json")
assert payload.get("stream") is True # 数据面当前只有流式形态
async with httpx.AsyncClient(trust_env=False, timeout=self._timeout) as client:
async with client.stream(
"POST",
f"{self._base_url}/v1/chat/completions",
json=payload,
headers=self._headers,
) as response:
if response.status_code >= 400:
body = (await response.aread()).decode("utf-8", errors="replace")
code = f"HJ-2{max(response.status_code - 400, 0):03d}"
try:
detail = json.loads(body)
code = detail.get("error", {}).get("code", code)
body = detail.get("error", {}).get("message", body)
except json.JSONDecodeError:
pass
raise ShenshiError(code, body)
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
data = line[len("data:") :].strip()
if data == "[DONE]":
return
if data:
yield ShenshiDelta.model_validate_json(data)
def build_shenshi_client(base_url: str, service_token: str) -> ShenshiClient:
"""按配置装配客户端:未配置神识地址时降级为回声桩(M0 骨架形态)。"""
if base_url:
return HttpShenshiClient(base_url, service_token)
return EchoShenshi()