Files
hunjing/backend/app/shenshi_client/client.py
T
chenxin 144415247e M4 收口:神识用户面与计量闭环——BYOK/配额三段式/usage 落库/Anthropic+Gemini 适配器/神识阁
- 神识 Go:store 双实现(PG 自动建表+60s 就绪重试 / 内存回退)承载 usage_logs·byok_keys·quota_balances;BYOK 密钥库(AES-GCM 加密、API 脱敏、撤回即失效、主密钥未配置 fail-closed);配额三段式(预扣 max_tokens 钳位 1M→结算实际 token→全败幂等退款,HJ-2004);计量归属(IR user_id→usage_logs,/me/usage 用户维度聚合+配额总览,/admin/usage/sample 对账抽样);Anthropic/Gemini 出站适配器(SSE 解析、system 提升、用量映射);stub 端点补名修复计量空 endpoint
- IR 契约:user_id/byok_key_id 双端同步(Go types.go + Py types.py)
- 灵台:runs.owner_id(迁移 005,server_default 兼容存量)经 kernel 全链透传为 IR user_id;shenshi_client 扩展用户面方法(X-Hunjing-User-Id 代理,Echo 桩带内存实现);/me/byok-keys 登记·列表·撤回与 /me/usage 端点(4 用例)
- 前端神识阁(/models 重建):偏好(生成参数表单)/自带密钥(登记加密保存、脱敏列表、撤回二次确认)/用量(聚合表+配额进度条)三区;me API 库+mock+5 用例;侧栏恢复入口
- 配置与部署:.env.example 增 SHENSHI_DATABASE_URL/SHENSHI_BYOK_MASTER_KEY/SHENSHI_USER_BUDGET_TOKENS;compose 神识容器接自有库(init-shenshi.sql 预建 hunjing_shenshi,与灵台不共库共表)
- 证据:Go 34 用例(BYOK 独占/配额拒绝/结算退款/归属聚合)、后端 532、前端 210、BFF 4 全绿;跨进程 E2E 四场景(灵台 HttpShenshiClient→真 Go 神识:登记脱敏/BYOK 独占不可达整体失败/用量归属聚合/撤回即失效)通过;rewrite-plan M4 标记完成、能力矩阵神识行升已验证
2026-08-27 18:39:17 +08:00

192 lines
7.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 Any, Protocol
import httpx
from app.shenshi_client.types import ShenshiChatRequest, ShenshiDelta, ShenshiError
class ShenshiClient(Protocol):
"""神识数据面契约:流式对话补全 + 用户面(BYOK/用量,神识阁数据源)。"""
def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]: ...
async def get_my_usage(self, user_id: str) -> dict[str, Any]: ...
async def list_byok_keys(self, user_id: str) -> list[dict[str, Any]]: ...
async def register_byok_key(
self, user_id: str, *, name: str, type: str, base_url: str, api_key: str, model: str
) -> dict[str, Any]: ...
async def revoke_byok_key(self, user_id: str, key_id: str) -> None: ...
class EchoShenshi:
"""进程内回声桩:把最后一条用户消息按固定粒度切分回流。
数据面只用于骨架验证(登录 → 占位 Run → SSE 端到端);用户面为最小内存实现,
供神识阁在无神识部署时演示与测试桩。
"""
chunk_size = 8
def __init__(self) -> None:
self._byok: dict[str, list[dict[str, Any]]] = {}
self._usage: dict[str, list[dict[str, Any]]] = {}
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),
)
if request.user_id:
self._usage.setdefault(request.user_id, []).append(
{
"user_id": request.user_id,
"model": request.model or "",
"endpoint": "echo",
"requests": 1,
"ok": 1,
"prompt_tokens": max(len(prompt) // 4, 1),
"completion_tokens": max(len(text) // 4, 1),
}
)
async def get_my_usage(self, user_id: str) -> dict[str, Any]:
return {"rows": self._usage.get(user_id, [])}
async def list_byok_keys(self, user_id: str) -> list[dict[str, Any]]:
return self._byok.get(user_id, [])
async def register_byok_key(
self, user_id: str, *, name: str, type: str, base_url: str, api_key: str, model: str
) -> dict[str, Any]:
entry = {
"id": f"byok_echo_{len(self._byok.get(user_id, [])) + 1}",
"name": name,
"type": type,
"base_url": base_url,
"model": model,
"api_key_mask": api_key[:3] + "****",
}
self._byok.setdefault(user_id, []).append(entry)
return entry
async def revoke_byok_key(self, user_id: str, key_id: str) -> None:
self._byok[user_id] = [k for k in self._byok.get(user_id, []) if k["id"] != key_id]
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
def _user_headers(self, user_id: str) -> dict[str, str]:
return {**self._headers, "X-Hunjing-User-Id": user_id}
async def _request_json(
self, method: str, path: str, *, user_id: str, json_body: dict[str, Any] | None = None
) -> Any:
async with httpx.AsyncClient(trust_env=False, timeout=self._timeout) as client:
response = await client.request(
method,
f"{self._base_url}{path}",
json=json_body,
headers=self._user_headers(user_id),
)
if response.status_code >= 400:
code = f"HJ-2{max(response.status_code - 400, 0):03d}"
try:
detail = response.json()
code = detail.get("error", {}).get("code", code)
message = detail.get("error", {}).get("message", response.text)
except ValueError:
message = response.text
raise ShenshiError(code, message)
if response.status_code == 204 or not response.content:
return None
return response.json()
async def get_my_usage(self, user_id: str) -> dict[str, Any]:
data = await self._request_json("GET", "/me/usage", user_id=user_id)
return data or {}
async def list_byok_keys(self, user_id: str) -> list[dict[str, Any]]:
data = await self._request_json("GET", "/me/byok-keys", user_id=user_id)
return (data or {}).get("keys", [])
async def register_byok_key(
self, user_id: str, *, name: str, type: str, base_url: str, api_key: str, model: str
) -> dict[str, Any]:
data = await self._request_json(
"POST",
"/me/byok-keys",
user_id=user_id,
json_body={"name": name, "type": type, "base_url": base_url, "api_key": api_key, "model": model},
)
return data or {}
async def revoke_byok_key(self, user_id: str, key_id: str) -> None:
await self._request_json("DELETE", f"/me/byok-keys/{key_id}", user_id=user_id)
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()