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 标记完成、能力矩阵神识行升已验证
This commit is contained in:
1 parent
462d570751
commit
144415247e
51 files changed
+3533
-90
No files matched your search
@@ -639,6 +639,14 @@ SHENSHI_SERVICE_TOKEN=
|
|||||||
SHENSHI_TIMEOUT_SECONDS=120.0
|
SHENSHI_TIMEOUT_SECONDS=120.0
|
||||||
# 神识服务监听端口(shenshi 服务自身)
|
# 神识服务监听端口(shenshi 服务自身)
|
||||||
SHENSHI_PORT=8080
|
SHENSHI_PORT=8080
|
||||||
|
# 神识自有库(usage_logs/byok_keys/quota_balances;未配置时进程内存储,重启即失)
|
||||||
|
# 本地开发可留空;正式环境指向 hunjing-postgres(如 postgresql://hunjing:hunjing@hunjing-postgres:5432/hunjing_shenshi)
|
||||||
|
SHENSHI_DATABASE_URL=
|
||||||
|
# BYOK 主密钥(32 字节 hex 或 base64;未配置时 BYOK 功能整体禁用 fail-closed)
|
||||||
|
# 生成示例:python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SHENSHI_BYOK_MASTER_KEY=
|
||||||
|
# 用户级软配额(token 口径;0 = 不限制,P0/P1 只做预算熔断不做计费)
|
||||||
|
SHENSHI_USER_BUDGET_TOKENS=0
|
||||||
# 神识出站端点目录(JSON 数组;未配置时回声桩兜底)。
|
# 神识出站端点目录(JSON 数组;未配置时回声桩兜底)。
|
||||||
# type: openai_compatible(云厂商兼容面与元炁 http://hunjing-yuanqi:11434 共用)
|
# type: openai_compatible(云厂商兼容面与元炁 http://hunjing-yuanqi:11434 共用)
|
||||||
# 示例:
|
# 示例:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""运行归属用户列_runs_owner_id_用量计量透传神识
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
Create Date: 2026-08-27 10:31:10.225717
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '005'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '004'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_factual_records_canonization_id'), 'factual_records', ['canonization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_factual_records_character_id'), 'factual_records', ['character_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_memory_triggers_memory_id'), 'memory_triggers', ['memory_id'], unique=False)
|
||||||
|
op.add_column('runs', sa.Column('owner_id', sa.String(length=80), server_default='', nullable=False))
|
||||||
|
op.create_index(op.f('ix_runs_owner_id'), 'runs', ['owner_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_runs_owner_id'), table_name='runs')
|
||||||
|
op.drop_column('runs', 'owner_id')
|
||||||
|
op.drop_index(op.f('ix_memory_triggers_memory_id'), table_name='memory_triggers')
|
||||||
|
op.drop_index(op.f('ix_factual_records_character_id'), table_name='factual_records')
|
||||||
|
op.drop_index(op.f('ix_factual_records_canonization_id'), table_name='factual_records')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -17,6 +17,7 @@ from app.api.v1.endpoints import (
|
|||||||
jingsheng_characters,
|
jingsheng_characters,
|
||||||
kernel_runs,
|
kernel_runs,
|
||||||
lorebooks,
|
lorebooks,
|
||||||
|
me,
|
||||||
mirror_trees,
|
mirror_trees,
|
||||||
mcp,
|
mcp,
|
||||||
personality,
|
personality,
|
||||||
@@ -37,6 +38,7 @@ api_router.include_router(characters.router)
|
|||||||
api_router.include_router(character_import.router)
|
api_router.include_router(character_import.router)
|
||||||
api_router.include_router(runs.router)
|
api_router.include_router(runs.router)
|
||||||
api_router.include_router(kernel_runs.router)
|
api_router.include_router(kernel_runs.router)
|
||||||
|
api_router.include_router(me.router)
|
||||||
api_router.include_router(embryo_drafts.router)
|
api_router.include_router(embryo_drafts.router)
|
||||||
api_router.include_router(jingsheng_characters.router)
|
api_router.include_router(jingsheng_characters.router)
|
||||||
api_router.include_router(mirror_trees.router)
|
api_router.include_router(mirror_trees.router)
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ async def rehearse_embryo(
|
|||||||
f"身份锚点:{json.dumps(baseline, ensure_ascii=False)}"
|
f"身份锚点:{json.dumps(baseline, ensure_ascii=False)}"
|
||||||
)
|
)
|
||||||
run = await kernel.start_rehearsal_run(
|
run = await kernel.start_rehearsal_run(
|
||||||
embryo_id=embryo_id, prompt=payload.prompt, system_contract=system_contract
|
embryo_id=embryo_id, prompt=payload.prompt, system_contract=system_contract,
|
||||||
|
owner_id=user.owner_scope,
|
||||||
)
|
)
|
||||||
await jingsheng.record_rehearsal(
|
await jingsheng.record_rehearsal(
|
||||||
embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt
|
embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt
|
||||||
|
|||||||
@@ -50,6 +50,6 @@ async def create_kernel_run(
|
|||||||
db, payload.character_id, owner_scope=user.owner_scope
|
db, payload.character_id, owner_scope=user.owner_scope
|
||||||
)
|
)
|
||||||
run = await kernel.start_foreground_run(
|
run = await kernel.start_foreground_run(
|
||||||
character_id=character.id, prompt=payload.prompt
|
character_id=character.id, prompt=payload.prompt, owner_id=user.owner_scope
|
||||||
)
|
)
|
||||||
return await get_run_response(run.id)
|
return await get_run_response(run.id)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""用户面 API(神识阁数据源):BYOK 密钥管理与用量查询。
|
||||||
|
|
||||||
|
灵台是神识唯一调用方:本端点鉴权后以 ``X-Hunjing-User-Id`` 代理转发;
|
||||||
|
密钥明文只进不出(登记请求体 → 神识加密落库,响应永远脱敏)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
|
||||||
|
from app.api.v1.deps import get_current_user
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.kernel import Kernel
|
||||||
|
from app.schemas.auth import CurrentUser
|
||||||
|
from app.schemas.me import (
|
||||||
|
ByokKeyListResponse,
|
||||||
|
MyUsageResponse,
|
||||||
|
RegisterByokKeyRequest,
|
||||||
|
)
|
||||||
|
from app.shenshi_client.client import ShenshiClient
|
||||||
|
from app.shenshi_client.types import ShenshiError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/me", tags=["me"])
|
||||||
|
|
||||||
|
|
||||||
|
def _shenshi(request: Request) -> ShenshiClient:
|
||||||
|
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
|
||||||
|
if kernel is None:
|
||||||
|
raise AppError("service_unavailable", "Kernel 未就绪。", status_code=503)
|
||||||
|
return kernel.shenshi
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/byok-keys", response_model=ByokKeyListResponse)
|
||||||
|
async def list_byok_keys(
|
||||||
|
request: Request, user: CurrentUser = Depends(get_current_user)
|
||||||
|
) -> ByokKeyListResponse:
|
||||||
|
keys = await _shenshi(request).list_byok_keys(user.owner_scope)
|
||||||
|
return ByokKeyListResponse(keys=keys)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/byok-keys", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register_byok_key(
|
||||||
|
payload: RegisterByokKeyRequest,
|
||||||
|
request: Request,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
key = await _shenshi(request).register_byok_key(
|
||||||
|
user.owner_scope,
|
||||||
|
name=payload.name,
|
||||||
|
type=payload.type,
|
||||||
|
base_url=payload.base_url,
|
||||||
|
api_key=payload.api_key,
|
||||||
|
model=payload.model,
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/byok-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def revoke_byok_key(
|
||||||
|
key_id: str, request: Request, user: CurrentUser = Depends(get_current_user)
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await _shenshi(request).revoke_byok_key(user.owner_scope, key_id)
|
||||||
|
except ShenshiError as exc:
|
||||||
|
raise AppError(exc.code, exc.message, status_code=404) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage", response_model=MyUsageResponse)
|
||||||
|
async def my_usage(
|
||||||
|
request: Request, user: CurrentUser = Depends(get_current_user)
|
||||||
|
) -> MyUsageResponse:
|
||||||
|
data = await _shenshi(request).get_my_usage(user.owner_scope)
|
||||||
|
return MyUsageResponse(rows=data.get("rows", []), quota=data.get("quota"))
|
||||||
@@ -521,6 +521,7 @@ async def spawn_answer(
|
|||||||
run = await kernel.start_foreground_run(
|
run = await kernel.start_foreground_run(
|
||||||
character_id=tree.character_id,
|
character_id=tree.character_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
|
owner_id=owner_scope,
|
||||||
system_contract=None,
|
system_contract=None,
|
||||||
origin="tree_answer",
|
origin="tree_answer",
|
||||||
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
|
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
|
||||||
@@ -620,6 +621,7 @@ async def _spawn_continuation(
|
|||||||
run = await kernel.start_foreground_run(
|
run = await kernel.start_foreground_run(
|
||||||
character_id=tree.character_id,
|
character_id=tree.character_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
|
owner_id=owner_scope,
|
||||||
origin=f"tree_{kind}",
|
origin=f"tree_{kind}",
|
||||||
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
|
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class Kernel:
|
|||||||
*,
|
*,
|
||||||
character_id: str,
|
character_id: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
|
owner_id: str = "",
|
||||||
trace_id: str | None = None,
|
trace_id: str | None = None,
|
||||||
system_contract: str | None = None,
|
system_contract: str | None = None,
|
||||||
origin: str = "kernel",
|
origin: str = "kernel",
|
||||||
@@ -63,6 +64,7 @@ class Kernel:
|
|||||||
async with self._sessions() as session:
|
async with self._sessions() as session:
|
||||||
run = Run(
|
run = Run(
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
|
owner_id=owner_id,
|
||||||
status="queued",
|
status="queued",
|
||||||
kind="foreground",
|
kind="foreground",
|
||||||
goal=prompt[:300] if prompt else None,
|
goal=prompt[:300] if prompt else None,
|
||||||
@@ -75,6 +77,7 @@ class Kernel:
|
|||||||
self._spawn(
|
self._spawn(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
|
owner_id=owner_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
seat_key=None,
|
seat_key=None,
|
||||||
@@ -89,13 +92,14 @@ class Kernel:
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
async def start_rehearsal_run(
|
async def start_rehearsal_run(
|
||||||
self, *, embryo_id: str, prompt: str, system_contract: str
|
self, *, embryo_id: str, prompt: str, system_contract: str, owner_id: str = ""
|
||||||
) -> Run:
|
) -> Run:
|
||||||
"""照心试演(幻境 Run):无正式人物、不占人物命台、不产生人物经历。"""
|
"""照心试演(幻境 Run):无正式人物、不占人物命台、不产生人物经历。"""
|
||||||
profile = resolve_profile()
|
profile = resolve_profile()
|
||||||
async with self._sessions() as session:
|
async with self._sessions() as session:
|
||||||
run = Run(
|
run = Run(
|
||||||
character_id=None,
|
character_id=None,
|
||||||
|
owner_id=owner_id,
|
||||||
status="queued",
|
status="queued",
|
||||||
kind="rehearsal",
|
kind="rehearsal",
|
||||||
goal=prompt[:300] if prompt else None,
|
goal=prompt[:300] if prompt else None,
|
||||||
@@ -108,6 +112,7 @@ class Kernel:
|
|||||||
self._spawn(
|
self._spawn(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
character_id=None,
|
character_id=None,
|
||||||
|
owner_id=owner_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
trace_id=None,
|
trace_id=None,
|
||||||
seat_key=f"embryo:{embryo_id}",
|
seat_key=f"embryo:{embryo_id}",
|
||||||
@@ -129,6 +134,7 @@ class Kernel:
|
|||||||
*,
|
*,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
character_id: str | None,
|
character_id: str | None,
|
||||||
|
owner_id: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
trace_id: str | None,
|
trace_id: str | None,
|
||||||
seat_key: str | None,
|
seat_key: str | None,
|
||||||
@@ -141,6 +147,7 @@ class Kernel:
|
|||||||
self.loop.execute(
|
self.loop.execute(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
|
owner_id=owner_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
seat_key=seat_key,
|
seat_key=seat_key,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class KernelRunLoop:
|
|||||||
run_id: str,
|
run_id: str,
|
||||||
character_id: str | None,
|
character_id: str | None,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
|
owner_id: str = "",
|
||||||
trace_id: str | None = None,
|
trace_id: str | None = None,
|
||||||
seat_key: str | None = None,
|
seat_key: str | None = None,
|
||||||
system_contract: str | None = None,
|
system_contract: str | None = None,
|
||||||
@@ -98,6 +99,7 @@ class KernelRunLoop:
|
|||||||
await self._execute(
|
await self._execute(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
|
owner_id=owner_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
trace_id=trace,
|
trace_id=trace,
|
||||||
seat_key=seat_key,
|
seat_key=seat_key,
|
||||||
@@ -127,6 +129,7 @@ class KernelRunLoop:
|
|||||||
*,
|
*,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
character_id: str | None,
|
character_id: str | None,
|
||||||
|
owner_id: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
seat_key: str | None,
|
seat_key: str | None,
|
||||||
@@ -153,6 +156,7 @@ class KernelRunLoop:
|
|||||||
await self._turn(
|
await self._turn(
|
||||||
run_id,
|
run_id,
|
||||||
character_id,
|
character_id,
|
||||||
|
owner_id,
|
||||||
trace_id,
|
trace_id,
|
||||||
prompt,
|
prompt,
|
||||||
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
|
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
|
||||||
@@ -165,6 +169,7 @@ class KernelRunLoop:
|
|||||||
self,
|
self,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
character_id: str | None,
|
character_id: str | None,
|
||||||
|
owner_id: str,
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
system_contract: str,
|
system_contract: str,
|
||||||
@@ -189,6 +194,7 @@ class KernelRunLoop:
|
|||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
|
user_id=owner_id or None,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class Run(Base):
|
|||||||
character_id: Mapped[str | None] = mapped_column(
|
character_id: Mapped[str | None] = mapped_column(
|
||||||
String(80), ForeignKey("characters.id"), index=True, nullable=True
|
String(80), ForeignKey("characters.id"), index=True, nullable=True
|
||||||
)
|
)
|
||||||
|
# Run 归属用户(用量计量与配额裁决维度,透传神识 user_id;空 = 平台调用)
|
||||||
|
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True)
|
||||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
|
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
|
||||||
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
|
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
|
||||||
kind: Mapped[str] = mapped_column(
|
kind: Mapped[str] = mapped_column(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""用户面 API schema(神识阁数据源:BYOK 密钥与用量)。"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
ByokType = str # openai_compatible / anthropic / gemini(契约词典 §14.2,神识侧校验)
|
||||||
|
|
||||||
|
|
||||||
|
class ByokKeyView(BaseModel):
|
||||||
|
"""BYOK 密钥脱敏形态(API 永不回明文)。"""
|
||||||
|
|
||||||
|
model_config = {"extra": "allow"} # 神识侧字段直通(id/name/type/base_url/model/api_key_mask/…)
|
||||||
|
|
||||||
|
|
||||||
|
class ByokKeyListResponse(BaseModel):
|
||||||
|
keys: list[dict] = Field(default_factory=list, description="脱敏密钥清单。")
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterByokKeyRequest(BaseModel):
|
||||||
|
"""登记用户自带密钥。"""
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=120, description="密钥备注名。")
|
||||||
|
type: ByokType = Field(description="端点类型:openai_compatible / anthropic / gemini。")
|
||||||
|
base_url: str = Field(min_length=1, max_length=500, description="Provider Base URL。")
|
||||||
|
api_key: str = Field(min_length=8, max_length=500, description="密钥明文(仅本次请求可见,落库即加密)。")
|
||||||
|
model: str = Field(min_length=1, max_length=200, description="该密钥承载的上游模型名。")
|
||||||
|
|
||||||
|
|
||||||
|
class UsageRow(BaseModel):
|
||||||
|
"""用量聚合行(用户×模型×端点)。"""
|
||||||
|
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class MyUsageResponse(BaseModel):
|
||||||
|
rows: list[dict] = Field(default_factory=list, description="用量聚合行。")
|
||||||
|
quota: dict | None = Field(default=None, description="配额总览(budget/used/reserved)。")
|
||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -19,19 +19,34 @@ from app.shenshi_client.types import ShenshiChatRequest, ShenshiDelta, ShenshiEr
|
|||||||
|
|
||||||
|
|
||||||
class ShenshiClient(Protocol):
|
class ShenshiClient(Protocol):
|
||||||
"""神识数据面契约:流式对话补全。"""
|
"""神识数据面契约:流式对话补全 + 用户面(BYOK/用量,神识阁数据源)。"""
|
||||||
|
|
||||||
def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]: ...
|
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:
|
class EchoShenshi:
|
||||||
"""进程内回声桩:把最后一条用户消息按固定粒度切分回流。
|
"""进程内回声桩:把最后一条用户消息按固定粒度切分回流。
|
||||||
|
|
||||||
只用于 M0 骨架验证(登录 → 占位 Run → SSE 端到端),不模拟任何 Provider 行为。
|
数据面只用于骨架验证(登录 → 占位 Run → SSE 端到端);用户面为最小内存实现,
|
||||||
|
供神识阁在无神识部署时演示与测试桩。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
chunk_size = 8
|
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]:
|
async def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]:
|
||||||
prompt = next((m.content for m in reversed(request.messages) if m.role == "user"), "")
|
prompt = next((m.content for m in reversed(request.messages) if m.role == "user"), "")
|
||||||
text = f"【回声桩】{prompt}"
|
text = f"【回声桩】{prompt}"
|
||||||
@@ -42,6 +57,41 @@ class EchoShenshi:
|
|||||||
usage_prompt_tokens=max(len(prompt) // 4, 1),
|
usage_prompt_tokens=max(len(prompt) // 4, 1),
|
||||||
usage_completion_tokens=max(len(text) // 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:
|
class HttpShenshiClient:
|
||||||
@@ -56,6 +106,54 @@ class HttpShenshiClient:
|
|||||||
self._headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
|
self._headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
|
||||||
self._timeout = timeout
|
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]:
|
async def stream_chat(self, request: ShenshiChatRequest) -> AsyncIterator[ShenshiDelta]:
|
||||||
payload = request.model_dump(mode="json")
|
payload = request.model_dump(mode="json")
|
||||||
assert payload.get("stream") is True # 数据面当前只有流式形态
|
assert payload.get("stream") is True # 数据面当前只有流式形态
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class ShenshiChatRequest(BaseModel):
|
|||||||
run_id: str
|
run_id: str
|
||||||
trace_id: str | None = None
|
trace_id: str | None = None
|
||||||
character_id: str | None = None
|
character_id: str | None = None
|
||||||
|
# 用量归属与配额裁决维度(鉴权后注入;空则记为平台调用)
|
||||||
|
user_id: str | None = None
|
||||||
|
# 用户自带密钥引用(配置继承的最终解析结果,神识不自行决定)
|
||||||
|
byok_key_id: str | None = None
|
||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
stream: bool = True
|
stream: bool = True
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""用户面(神识阁数据源)端点测试:BYOK 管理与用量查询,经回声桩客户端端到端。"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.kernel import Kernel
|
||||||
|
from app.main import app
|
||||||
|
from app.services.event_store import DbEventStore
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.kernel import KernelEventLog
|
||||||
|
from app.shenshi_client.client import EchoShenshi
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||||
|
) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""手工装配 Kernel(回声桩客户端)到 app.state(测试不经 lifespan,与既有做法一致)。"""
|
||||||
|
app.state.kernel = Kernel(
|
||||||
|
shenshi=EchoShenshi(),
|
||||||
|
session_factory=AsyncSessionLocal,
|
||||||
|
event_log=KernelEventLog(DbEventStore(AsyncSessionLocal)),
|
||||||
|
timeout_seconds=10.0,
|
||||||
|
)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
|
||||||
|
yield c
|
||||||
|
app.state.kernel = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_byok_register_list_revoke(client: AsyncClient) -> None:
|
||||||
|
payload = {
|
||||||
|
"name": "自带 DeepSeek",
|
||||||
|
"type": "openai_compatible",
|
||||||
|
"base_url": "https://api.deepseek.com",
|
||||||
|
"api_key": "sk-very-secret-value",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
}
|
||||||
|
created = await client.post("/api/v1/me/byok-keys", json=payload)
|
||||||
|
assert created.status_code == 201
|
||||||
|
body = created.json()
|
||||||
|
# 脱敏:响应不得含明文密钥
|
||||||
|
assert "sk-very-secret-value" not in body
|
||||||
|
assert "****" in body.get("api_key_mask", "")
|
||||||
|
key_id = body["id"]
|
||||||
|
|
||||||
|
listed = await client.get("/api/v1/me/byok-keys")
|
||||||
|
assert listed.status_code == 200
|
||||||
|
keys = listed.json()["keys"]
|
||||||
|
assert [k["id"] for k in keys] == [key_id]
|
||||||
|
assert all("sk-very-secret-value" not in str(k) for k in keys)
|
||||||
|
|
||||||
|
revoked = await client.delete(f"/api/v1/me/byok-keys/{key_id}")
|
||||||
|
assert revoked.status_code == 204
|
||||||
|
after = await client.get("/api/v1/me/byok-keys")
|
||||||
|
assert after.json()["keys"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_byok_register_validation(client: AsyncClient) -> None:
|
||||||
|
bad = await client.post(
|
||||||
|
"/api/v1/me/byok-keys",
|
||||||
|
json={"name": "x", "type": "unknown_type", "base_url": "https://x", "api_key": "k" * 8, "model": "m"},
|
||||||
|
)
|
||||||
|
# 类型枚举由神识侧裁决;回声桩直通,这里校验必填字段约束
|
||||||
|
missing = await client.post("/api/v1/me/byok-keys", json={"name": "x"})
|
||||||
|
assert missing.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_my_usage_shape(client: AsyncClient) -> None:
|
||||||
|
resp = await client.get("/api/v1/me/usage")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "rows" in body
|
||||||
|
assert isinstance(body["rows"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_me_requires_auth() -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
resp = await c.get("/api/v1/me/byok-keys")
|
||||||
|
assert resp.status_code in (401, 503) # 未携带凭证被拒(JWT 未配置时 fail-closed)
|
||||||
+1
-1
@@ -55,7 +55,7 @@
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
重点修改:`POSTGRES_PASSWORD`、`SHENSHI_ENDPOINTS`(神识模型端点目录,JSON 数组,示例见 `.env.example`)。若使用 NebulaGraph,确认 `GRAPH_DB_TYPE=nebulagraph`。
|
重点修改:`POSTGRES_PASSWORD`、`SHENSHI_ENDPOINTS`(神识模型端点目录,JSON 数组,示例见 `.env.example`)、`SHENSHI_BYOK_MASTER_KEY`(启用 BYOK,可选)。神识自有库 `hunjing_shenshi` 由 `docker/postgres/init-shenshi.sql` 首次初始化时自动创建。若使用 NebulaGraph,确认 `GRAPH_DB_TYPE=nebulagraph`。
|
||||||
|
|
||||||
2. 应用层的 `environment` 会自动覆盖容器网络服务发现地址(如 `POSTGRES_HOST=hunjing-postgres`),无需手动改 `.env` 里的 `127.0.0.1`。
|
2. 应用层的 `environment` 会自动覆盖容器网络服务发现地址(如 `POSTGRES_HOST=hunjing-postgres`),无需手动改 `.env` 里的 `127.0.0.1`。
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ services:
|
|||||||
limits: { memory: 1G }
|
limits: { memory: 1G }
|
||||||
reservations: { memory: 256M }
|
reservations: { memory: 256M }
|
||||||
|
|
||||||
# ============================== 神识 · 模型网关(Go,rewrite/1.0 M0) ==============================
|
# ============================== 神识 · 模型网关(Go,rewrite/1.0 M4) ==============================
|
||||||
# 灵台唯一模型出口:IR 入站 → 适配器(当前 stub,云 Provider/元炁按 M4 接入)→ SSE 出站。
|
# 灵台唯一模型出口:IR 入站 → 适配器(openai_compatible/anthropic/gemini/local/stub)→ SSE 出站。
|
||||||
|
# 计量/BYOK/配额:SHENSHI_DATABASE_URL 指向独立库(与灵台不共库共表,需在 PG 预建
|
||||||
|
# hunjing_shenshi database;未配置时进程内存储,重启即失)。
|
||||||
# 鉴权:SHENSHI_SERVICE_TOKEN 经 env_file 注入;未配置时开发档放行。
|
# 鉴权:SHENSHI_SERVICE_TOKEN 经 env_file 注入;未配置时开发档放行。
|
||||||
hunjing-shenshi:
|
hunjing-shenshi:
|
||||||
build:
|
build:
|
||||||
@@ -88,6 +90,7 @@ services:
|
|||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
SHENSHI_PORT: "8080"
|
SHENSHI_PORT: "8080"
|
||||||
|
SHENSHI_DATABASE_URL: postgresql://hunjing:${POSTGRES_PASSWORD:-change-me}@hunjing-postgres:5432/hunjing_shenshi
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits: { memory: 256M }
|
limits: { memory: 256M }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ services:
|
|||||||
TZ: ${TIMEZONE:-Asia/Shanghai}
|
TZ: ${TIMEZONE:-Asia/Shanghai}
|
||||||
volumes:
|
volumes:
|
||||||
- hunjing-pg-data:/var/lib/postgresql/data
|
- hunjing-pg-data:/var/lib/postgresql/data
|
||||||
|
- ./postgres/init-shenshi.sql:/docker-entrypoint-initdb.d/10-init-shenshi.sql:ro
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 神识自有库(与灵台主库 hunjing 不共库共表,shenshi-gateway.md §3)
|
||||||
|
-- PG 官方镜像首次初始化时执行;已存在的库不受影响。
|
||||||
|
CREATE DATABASE hunjing_shenshi;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 神识模型网关
|
# 神识模型网关
|
||||||
|
|
||||||
> 状态:重写目标态(rewrite/1.0 基线),重写期间新建,一次性替换灵台 Python `ai_provider`
|
> 状态:rewrite/1.0 M4 已落地(2026-08-27)——数据面/路由/故障切换/BYOK/配额三段式/计量落库/Anthropic+Gemini 适配器/用户面(灵台 /me/* 代理 + 前端神识阁);官方云计费(P2)与生产 PG 档联调随标准档部署复核
|
||||||
> 参考项目只借设计不复制源码:octopus(IR 双向转换、首字超时切道)、new-api(计费不变量、渠道 adaptor 分层)、lens(模型组与冷却粒度)、metapi(路由可解释)、axonhub(协议集成测试矩阵)。
|
> 参考项目只借设计不复制源码:octopus(IR 双向转换、首字超时切道)、new-api(计费不变量、渠道 adaptor 分层)、lens(模型组与冷却粒度)、metapi(路由可解释)、axonhub(协议集成测试矩阵)。
|
||||||
|
|
||||||
## 1. 定位与边界
|
## 1. 定位与边界
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| 镜生闭环(灵胚/启镜/魂籍/命台) | M1 | 局部实现 | M1 后端闭环已验证(本地开发档,2026-08-21):`backend/tests/test_jingsheng.py` 7 用例——启镜前零人物行/零 activated 事件、并发启镜原子闸门单赢家(409 HJ-1103)、命台租约冲突被拒且 `seat.contested` 留痕、过期接管 epoch 递增、魂籍摘要+签名双校验(篡改与移植冒充均 422)、死亡冷静期撤销/落定(数据保留);随缘候选生成与角色卡导入对接随 M2,前端人物页随 M2+ |
|
| 镜生闭环(灵胚/启镜/魂籍/命台) | M1 | 局部实现 | M1 后端闭环已验证(本地开发档,2026-08-21):`backend/tests/test_jingsheng.py` 7 用例——启镜前零人物行/零 activated 事件、并发启镜原子闸门单赢家(409 HJ-1103)、命台租约冲突被拒且 `seat.contested` 留痕、过期接管 epoch 递增、魂籍摘要+签名双校验(篡改与移植冒充均 422)、死亡冷静期撤销/落定(数据保留);随缘候选生成与角色卡导入对接随 M2,前端人物页随 M2+ |
|
||||||
| 镜树对话(问节/候答/采用/归真/引文/移栽) | M2 | 局部实现 | M2a+M2b 后端完成(2026-08-21):`test_mirror_tree.py` 8 用例钉死 PRD 场景 1/2/3/5/6/7/8;`test_import_and_assembly.py` 3 用例覆盖 ST V1/V2 卡→灵胚→启镜物化世谱、{{char}}/{{user}} 符文展开、火候(GenerationPreferences)进入 IR;旧 chat 模块/表/页面已删除(迁移 003,845 passed 仅余 7 个预存失败);前端镜树页随 M2c |
|
| 镜树对话(问节/候答/采用/归真/引文/移栽) | M2 | 局部实现 | M2a+M2b 后端完成(2026-08-21):`test_mirror_tree.py` 8 用例钉死 PRD 场景 1/2/3/5/6/7/8;`test_import_and_assembly.py` 3 用例覆盖 ST V1/V2 卡→灵胚→启镜物化世谱、{{char}}/{{user}} 符文展开、火候(GenerationPreferences)进入 IR;旧 chat 模块/表/页面已删除(迁移 003,845 passed 仅余 7 个预存失败);前端镜树页随 M2c |
|
||||||
| 三级记忆(微痕/明忆/重忆/实录/心痕) | M3 | 局部实现 | 后端闭环已验证(2026-08-22):`test_xirang.py` 7 用例覆盖六条验收——归真触发提取、白名单(persona/instruction/episodic)自动生效、重忆 pending 确认、deny/删除墓碑全链路停用、正影版本链、双通道 RRF 召回注入;M3b 补齐:世谱激活注入投影(关键词+常驻+预算+符文展开)、前端 `/memories` 治理页(五分区/确认/正影/删除/实录溯源);pgvector 语义通道随标准档 |
|
| 三级记忆(微痕/明忆/重忆/实录/心痕) | M3 | 局部实现 | 后端闭环已验证(2026-08-22):`test_xirang.py` 7 用例覆盖六条验收——归真触发提取、白名单(persona/instruction/episodic)自动生效、重忆 pending 确认、deny/删除墓碑全链路停用、正影版本链、双通道 RRF 召回注入;M3b 补齐:世谱激活注入投影(关键词+常驻+预算+符文展开)、前端 `/memories` 治理页(五分区/确认/正影/删除/实录溯源);pgvector 语义通道随标准档 |
|
||||||
| 神识 Go 网关 | M4 | 局部实现 | M4a 已验证(2026-08-27):OpenAI 兼容适配器(元炁共用)、SHENSHI_ENDPOINTS 端点目录、候选路由(优先级/粘性/冷却排尾)、首字超时切道(首帧前不提交)、凭证级 vs 绑定级冷却、已写字节不换道、计量日志+`/me/usage`;Go 10 用例含四场景故障注入,跨进程 E2E 通过;M4b 待:ai_provider 删除切换、BYOK、配额三段式、usage PG 落库、Anthropic/Gemini |
|
| 神识 Go 网关 | M4 | 已验证 | M4 收口(2026-08-27,本地开发档 + compose PG 档设计):M4a 基础上补齐——ai_provider 删除手术完成(灵台 Provider SDK import 归零,rg 审计);Anthropic/Gemini 适配器(SSE 解析 + system 提升 + 用量映射,httptest 往返用例);BYOK(AES-GCM 加密落库、API 脱敏、独占路由不回落、撤回即失效、主密钥未配置 fail-closed);配额三段式(预扣 max_tokens 钳位 1M → 结算实际 token → 全败幂等退款,HJ-2004);usage 归属计量(IR user_id → usage_logs,/me/usage 按 用户×模型×端点 聚合,/admin/usage/sample 对账抽样);神识自有库(PG 双实现 + 内存回退,启动 60s 就绪重试);灵台 /me/byok-keys|/me/usage 代理 + 神识阁前端三区(偏好/BYOK/用量)。Go 34 用例全绿(含 BYOK 独占/配额拒绝/结算退款/归属聚合六场景);跨进程 E2E 四场景(登记脱敏/独占失败/归属聚合/撤回)经灵台 HttpShenshiClient→真 Go 神识通过;后端 532、前端 210 passed。PG 生产档联调随标准档部署复核 |
|
||||||
| 百工与司命(授契/行契/请命/禁域/行动账) | M5 | 局部实现 | 乾坤沙箱(HMAC/进程隔离)已有工程与测试;能力注册与四维裁决待重写 |
|
| 百工与司命(授契/行契/请命/禁域/行动账) | M5 | 局部实现 | 乾坤沙箱(HMAC/进程隔离)已有工程与测试;能力注册与四维裁决待重写 |
|
||||||
| 缘契/人格演进/河图世界 | M6 | 未实现 | 旧 personality/drive/evolution/world 待退役 |
|
| 缘契/人格演进/河图世界 | M6 | 未实现 | 旧 personality/drive/evolution/world 待退役 |
|
||||||
| 形仪与语音(真形/Live2D/TTS/ASR/通感) | M7 | 局部实现 | 天目 OCR、元炁本地推理可用;形仪域未建 |
|
| 形仪与语音(真形/Live2D/TTS/ASR/通感) | M7 | 局部实现 | 天目 OCR、元炁本地推理可用;形仪域未建 |
|
||||||
|
|||||||
@@ -109,12 +109,13 @@
|
|||||||
- 证据(M3b,2026-08-22):世谱激活进入投影(关键词命中 + 常驻条目、insertion_order 排序、字符预算、条目内容符文展开,`test_xirang.py` 世谱注入用例);前端 `/memories` 治理页(待确认/候选箱/已生效/归档/实录五分区,确认/拒绝/正影/删除/来源展开,删除强确认),API 库 +3 用例;type-check 与 205 passed。
|
- 证据(M3b,2026-08-22):世谱激活进入投影(关键词命中 + 常驻条目、insertion_order 排序、字符预算、条目内容符文展开,`test_xirang.py` 世谱注入用例);前端 `/memories` 治理页(待确认/候选箱/已生效/归档/实录五分区,确认/拒绝/正影/删除/来源展开,删除强确认),API 库 +3 用例;type-check 与 205 passed。
|
||||||
- M3b 顺延项及理由:微痕时序效果(sticky/cooldown/delay)与次级键逻辑、递归扫描需要 Run 内轮次状态,随 M5 循环上下文落地一并实现;pgvector 语义通道随标准档部署接入(当前活力时近通道占位)。
|
- M3b 顺延项及理由:微痕时序效果(sticky/cooldown/delay)与次级键逻辑、递归扫描需要 Run 内轮次状态,随 M5 循环上下文落地一并实现;pgvector 语义通道随标准档部署接入(当前活力时近通道占位)。
|
||||||
|
|
||||||
### M4 神识完整接管 — 🔶 M4a 完成(2026-08-27),M4b 进行中
|
### M4 神识完整接管 — ✅ 已完成(2026-08-27,M4a 网关 + M4b 切换手术 + 用户面收口)
|
||||||
|
|
||||||
- 交付:Go 神识全量(transform adaptor:OpenAI 兼容+元炁;路由/冷却/粘性;计量;配额);灵台切换 `shenshi_client`;**删除 Python ai_provider 与相关配置/页面 models 旧实现**;神识阁用户面(偏好/BYOK/用量)。
|
- 交付:Go 神识全量(transform adaptor:OpenAI 兼容+元炁;路由/冷却/粘性;计量;配额);灵台切换 `shenshi_client`;**删除 Python ai_provider 与相关配置/页面 models 旧实现**;神识阁用户面(偏好/BYOK/用量)。
|
||||||
- 验收:故障注入(首字超时切道、凭证级冷却、熔断恢复)通过;usage 与 Provider 对账抽样一致;灵台内无 Provider SDK import(rg 审计为空)。
|
- 验收:故障注入(首字超时切道、凭证级冷却、熔断恢复)通过;usage 与 Provider 对账抽样一致;灵台内无 Provider SDK import(rg 审计为空)。
|
||||||
- 证据(M4a,2026-08-27):OpenAI 兼容适配器(元炁共用协议)+ `SHENSHI_ENDPOINTS` env 端点目录;候选路由(Priority+粘性置首+冷却排尾兜底)、首字超时切道(首帧前不提交响应,失败整体回退 JSON)、401/403 凭证级冷却 10 分钟 vs 429/5xx/超时绑定级 30 秒、已写字节不换道;计量(stdout JSON `shenshi_usage` + `/me/usage` 聚合)。Go 测试 10 用例全绿(含四场景故障注入:首字超时切道/凭证冷却跳过/全败 JSON/用量聚合);跨进程 E2E:灵台 `HttpShenshiClient` → 真 Go 神识 → stub 流式往返通过。假上游跨进程拨号在本沙箱受出站限制,改以 Go 进程内 httptest 覆盖(同真实 TCP 语义)。
|
- 证据(M4a,2026-08-27):OpenAI 兼容适配器(元炁共用协议)+ `SHENSHI_ENDPOINTS` env 端点目录;候选路由(Priority+粘性置首+冷却排尾兜底)、首字超时切道(首帧前不提交响应,失败整体回退 JSON)、401/403 凭证级冷却 10 分钟 vs 429/5xx/超时绑定级 30 秒、已写字节不换道;计量(stdout JSON `shenshi_usage` + `/me/usage` 聚合)。Go 测试 10 用例全绿(含四场景故障注入:首字超时切道/凭证冷却跳过/全败 JSON/用量聚合);跨进程 E2E:灵台 `HttpShenshiClient` → 真 Go 神识 → stub 流式往返通过。假上游跨进程拨号在本沙箱受出站限制,改以 Go 进程内 httptest 覆盖(同真实 TCP 语义)。
|
||||||
- M4b 待办:灵台生产流量切换与 **Python ai_provider 删除**(rg 审计 Provider SDK 归零)、BYOK 密钥表与归属计量、配额三段式、usage PG 落库与对账抽样、Anthropic/Gemini 适配器、神识阁用户页。
|
- 证据(M4b,2026-08-27):手术清单 ①-⑥ 全量执行——后端删 `app/ai_provider/`(38 文件)与 `services/ai_provider_admin.py`、`core/config/model_config.py`、`tests/ai_provider/`(21 文件);连带退役旧 Agent 循环(`application/agent/`、steer/follow-up/确认注册表、`app/skills/` 孤儿、`endpoints/audio.py`、`application/imports.py`);runs.py 修剪为 kernel 契约端点,safety 确认改直接落库裁决(pending→approved/denied);前端删 models 页/gateway.ts(玄关)/网关组件/audio/types/mock;config 三 yaml 归档 `shenshi/config/`,`.env.example` 收敛 SHENSHI_*。后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check + 201 passed。
|
||||||
|
- 证据(M4 用户面收口,2026-08-27):神识补齐 BYOK(AES-GCM + 脱敏 + 独占路由 + fail-closed)、配额三段式(预扣钳位/结算/幂等退款,HJ-2004)、usage 归属计量与对账抽样(`/me/usage`、`/admin/usage/sample`)、Anthropic/Gemini 适配器、神识自有库 store(PG + 内存回退,60s 就绪重试);IR 契约扩展 `user_id`/`byok_key_id`(Go+Py 同步);灵台 runs.owner_id(迁移 005)经 kernel 透传 IR,`/me/byok-keys`|`/me/usage` 代理端点 + 神识阁前端(偏好/BYOK/用量三区,撤回二次确认)。Go 34 用例、后端 532、前端 210 passed;跨进程 E2E 四场景(灵台 HttpShenshiClient → 真 Go 神识:登记脱敏/独占失败/归属聚合/撤回)通过。rg 审计灵台 Provider SDK import 归零。
|
||||||
- M4b 手术清单(2026-08-27 侦察固化,30+ 文件):
|
- M4b 手术清单(2026-08-27 侦察固化,30+ 文件):
|
||||||
① 后端删除:`app/ai_provider/`(26 文件,含 anthropic/openai 格式与 billing——神识已承接)、`services/ai_provider_admin.py`、`core/config/model_config.py`、`tests/ai_provider/`(16 文件);
|
① 后端删除:`app/ai_provider/`(26 文件,含 anthropic/openai 格式与 billing——神识已承接)、`services/ai_provider_admin.py`、`core/config/model_config.py`、`tests/ai_provider/`(16 文件);
|
||||||
② 连带退役:`application/agent/`(旧 Agent 循环,steer/follow-up/确认注册表——live UI 已随 chat 页消亡,天工阁 M8 重建);`endpoints/audio.py`(ASR/TTS 归 P1 形仪域);`endpoints/settings.py` 与 `services/preferences.py` 中模型偏好字段(切神识阁);admin 网关端点与 `test_model_gateway_endpoints.py`;`main.py` 的 `reload_provider_manager`、conftest 的 endpoint 覆盖缓存清理;
|
② 连带退役:`application/agent/`(旧 Agent 循环,steer/follow-up/确认注册表——live UI 已随 chat 页消亡,天工阁 M8 重建);`endpoints/audio.py`(ASR/TTS 归 P1 形仪域);`endpoints/settings.py` 与 `services/preferences.py` 中模型偏好字段(切神识阁);admin 网关端点与 `test_model_gateway_endpoints.py`;`main.py` 的 `reload_provider_manager`、conftest 的 endpoint 覆盖缓存清理;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { PageContainer } from "@/components/layout/PageContainer";
|
||||||
|
import { ShenshiPavilion } from "@/components/shenshi/ShenshiPavilion";
|
||||||
|
import { serverApi } from "@/lib/api/server-client";
|
||||||
|
import { describeError } from "@/lib/utils/error";
|
||||||
|
import type { ByokKey, GenerationPreferences, MyUsage } from "@/types/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 神识阁:模型偏好 / 自带密钥(BYOK)/ 用量。
|
||||||
|
*
|
||||||
|
* 偏好来自灵台 /settings/preferences;BYOK 与用量经灵台 /me/* 代理神识。
|
||||||
|
*/
|
||||||
|
export default async function ShenshiPage() {
|
||||||
|
let preferences: GenerationPreferences | null = null;
|
||||||
|
let keys: ByokKey[] = [];
|
||||||
|
let usage: MyUsage | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
try {
|
||||||
|
const [prefs, byok, myUsage] = await Promise.all([
|
||||||
|
serverApi.get<GenerationPreferences>("/settings/preferences"),
|
||||||
|
serverApi.get<{ keys: ByokKey[] }>("/me/byok-keys"),
|
||||||
|
serverApi.get<MyUsage>("/me/usage"),
|
||||||
|
]);
|
||||||
|
preferences = prefs;
|
||||||
|
keys = byok.keys ?? [];
|
||||||
|
usage = myUsage;
|
||||||
|
} catch (e) {
|
||||||
|
error = describeError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="神识阁"
|
||||||
|
description="模型偏好 · 自带密钥 · 用量与配额(神识网关统一接管)"
|
||||||
|
>
|
||||||
|
<ShenshiPavilion
|
||||||
|
initialPreferences={preferences}
|
||||||
|
initialKeys={keys}
|
||||||
|
initialUsage={usage}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CharacterIcon,
|
CharacterIcon,
|
||||||
CosmosIcon,
|
CosmosIcon,
|
||||||
DriveIcon,
|
DriveIcon,
|
||||||
|
ModelIcon,
|
||||||
PersonalityIcon,
|
PersonalityIcon,
|
||||||
RunIcon,
|
RunIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -93,6 +94,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
title: "百工",
|
title: "百工",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/tools", label: "工具", icon: <ToolsIcon /> },
|
{ href: "/tools", label: "工具", icon: <ToolsIcon /> },
|
||||||
|
{ href: "/models", label: "神识阁", icon: <ModelIcon /> },
|
||||||
{ href: "/settings", label: "设置", icon: <SettingsIcon /> },
|
{ href: "/settings", label: "设置", icon: <SettingsIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { ShenshiPavilion } from "./ShenshiPavilion";
|
||||||
|
import { meApi, settingsApi } from "@/lib/api";
|
||||||
|
import type { ByokKey, GenerationPreferences, MyUsage } from "@/types/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
meApi: {
|
||||||
|
registerByokKey: vi.fn(),
|
||||||
|
revokeByokKey: vi.fn(),
|
||||||
|
listByokKeys: vi.fn(),
|
||||||
|
getMyUsage: vi.fn(),
|
||||||
|
},
|
||||||
|
settingsApi: {
|
||||||
|
updateGenerationPreferences: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const prefs: GenerationPreferences = {
|
||||||
|
default_model_hint: "deepseek-v4-flash",
|
||||||
|
temperature: 0.7,
|
||||||
|
top_p: 1,
|
||||||
|
max_output_tokens: 4096,
|
||||||
|
streaming: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const keys: ByokKey[] = [
|
||||||
|
{
|
||||||
|
id: "byok_1",
|
||||||
|
name: "主力 DeepSeek",
|
||||||
|
type: "openai_compatible",
|
||||||
|
base_url: "https://api.deepseek.com",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
api_key_mask: "sk-****",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const usage: MyUsage = {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
endpoint: "deepseek",
|
||||||
|
requests: 12,
|
||||||
|
ok: 11,
|
||||||
|
prompt_tokens: 8400,
|
||||||
|
completion_tokens: 3100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quota: { budget: 1000000, used: 11500, reserved: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ShenshiPavilion(神识阁)", () => {
|
||||||
|
it("三区渲染:偏好 / BYOK 脱敏列表 / 用量聚合", () => {
|
||||||
|
render(
|
||||||
|
<ShenshiPavilion
|
||||||
|
initialPreferences={prefs}
|
||||||
|
initialKeys={keys}
|
||||||
|
initialUsage={usage}
|
||||||
|
error={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("生成参数")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("自带密钥")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("用量")).toBeInTheDocument();
|
||||||
|
// 密钥只显脱敏掩码,不显明文(初始数据本就脱敏,这里钉死展示语义)
|
||||||
|
expect(screen.getByText(/sk-\*\*\*\*/)).toBeInTheDocument();
|
||||||
|
// 用量行与配额呈现
|
||||||
|
expect(screen.getByText("deepseek-v4-flash")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/11,500/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("登记 BYOK:必填校验与提交载荷", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<ShenshiPavilion
|
||||||
|
initialPreferences={prefs}
|
||||||
|
initialKeys={[]}
|
||||||
|
initialUsage={null}
|
||||||
|
error={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 空列表时表单默认展开
|
||||||
|
expect(screen.getByLabelText(/Base URL/i)).toBeVisible();
|
||||||
|
await user.click(screen.getByRole("button", { name: /登记并加密保存/ }));
|
||||||
|
expect(await screen.findByText(/均为必填/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
vi.mocked(meApi.registerByokKey).mockResolvedValue(keys[0]);
|
||||||
|
vi.mocked(meApi.listByokKeys).mockResolvedValue({ keys });
|
||||||
|
await user.type(screen.getByLabelText(/备注名/), "主力 DeepSeek");
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText(/Base URL/i),
|
||||||
|
"https://api.deepseek.com",
|
||||||
|
);
|
||||||
|
await user.type(screen.getByLabelText(/API Key/), "sk-secret-value");
|
||||||
|
await user.type(screen.getByLabelText(/上游模型名/), "deepseek-v4-flash");
|
||||||
|
await user.click(screen.getByRole("button", { name: /登记并加密保存/ }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(meApi.registerByokKey).toHaveBeenCalledWith({
|
||||||
|
name: "主力 DeepSeek",
|
||||||
|
type: "openai_compatible",
|
||||||
|
base_url: "https://api.deepseek.com",
|
||||||
|
api_key: "sk-secret-value",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("撤回需二次确认", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(meApi.revokeByokKey).mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<ShenshiPavilion
|
||||||
|
initialPreferences={prefs}
|
||||||
|
initialKeys={keys}
|
||||||
|
initialUsage={null}
|
||||||
|
error={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const first = screen.getByRole("button", { name: "撤回" });
|
||||||
|
await user.click(first); // 一次点击 → 进入确认态,不发起请求
|
||||||
|
expect(meApi.revokeByokKey).not.toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole("button", { name: /确认撤回/ }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(meApi.revokeByokKey).toHaveBeenCalledWith("byok_1"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("偏好保存走 settings API", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(settingsApi.updateGenerationPreferences).mockResolvedValue(prefs);
|
||||||
|
render(
|
||||||
|
<ShenshiPavilion
|
||||||
|
initialPreferences={prefs}
|
||||||
|
initialKeys={keys}
|
||||||
|
initialUsage={null}
|
||||||
|
error={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /保存偏好/ }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(settingsApi.updateGenerationPreferences).toHaveBeenCalled(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("加载失败呈现错误态", () => {
|
||||||
|
render(
|
||||||
|
<ShenshiPavilion
|
||||||
|
initialPreferences={null}
|
||||||
|
initialKeys={[]}
|
||||||
|
initialUsage={null}
|
||||||
|
error="后端不可达"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/神识阁数据加载失败/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("后端不可达")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import {
|
||||||
|
ChartColumnIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CircleAlertIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "@/components/ui/Icons";
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
|
||||||
|
import { meApi, settingsApi } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
import type { ByokKey, GenerationPreferences, MyUsage } from "@/types/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialPreferences: GenerationPreferences | null;
|
||||||
|
initialKeys: ByokKey[];
|
||||||
|
initialUsage: MyUsage | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
const BYOK_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: "openai_compatible", label: "OpenAI 兼容" },
|
||||||
|
{ value: "anthropic", label: "Anthropic" },
|
||||||
|
{ value: "gemini", label: "Gemini" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 神识阁:模型偏好 / 自带密钥(BYOK)/ 用量 三区。
|
||||||
|
*
|
||||||
|
* 数据源:偏好=灵台 /settings/preferences;BYOK 与用量经灵台代理神识 /me/*。
|
||||||
|
* 密钥明文只在登记表单中出现,响应永远脱敏。
|
||||||
|
*/
|
||||||
|
export function ShenshiPavilion({
|
||||||
|
initialPreferences,
|
||||||
|
initialKeys,
|
||||||
|
initialUsage,
|
||||||
|
error,
|
||||||
|
}: Props) {
|
||||||
|
const [prefs, setPrefs] = useState<GenerationPreferences | null>(
|
||||||
|
initialPreferences,
|
||||||
|
);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [keys, setKeys] = useState<ByokKey[]>(initialKeys);
|
||||||
|
const [showForm, setShowForm] = useState(initialKeys.length === 0);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
type: "openai_compatible",
|
||||||
|
base_url: "",
|
||||||
|
api_key: "",
|
||||||
|
model: "",
|
||||||
|
});
|
||||||
|
const [byokError, setByokError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [usage, setUsage] = useState<MyUsage | null>(initialUsage);
|
||||||
|
const [usageAt] = useState(() => new Date());
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!prefs) return;
|
||||||
|
setSaveState("saving");
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const updated = await settingsApi.updateGenerationPreferences(prefs);
|
||||||
|
setPrefs(updated);
|
||||||
|
setSaveState("saved");
|
||||||
|
} catch (e) {
|
||||||
|
setSaveState("error");
|
||||||
|
setMessage(e instanceof Error ? e.message : "保存失败,请重试。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
setByokError(null);
|
||||||
|
if (!form.name || !form.base_url || !form.api_key || !form.model) {
|
||||||
|
setByokError("备注名、Base URL、密钥与模型名均为必填。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await meApi.registerByokKey(form);
|
||||||
|
const { keys: latest } = await meApi.listByokKeys();
|
||||||
|
setKeys(latest);
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
type: form.type,
|
||||||
|
base_url: "",
|
||||||
|
api_key: "",
|
||||||
|
model: "",
|
||||||
|
});
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (e) {
|
||||||
|
setByokError(e instanceof Error ? e.message : "登记失败,请重试。");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(keyId: string) {
|
||||||
|
if (confirmDelete !== keyId) {
|
||||||
|
setConfirmDelete(keyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await meApi.revokeByokKey(keyId);
|
||||||
|
setKeys((prev) => prev.filter((k) => k.id !== keyId));
|
||||||
|
setConfirmDelete(null);
|
||||||
|
} catch (e) {
|
||||||
|
setByokError(e instanceof Error ? e.message : "撤回失败,请重试。");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshUsage() {
|
||||||
|
try {
|
||||||
|
setUsage(await meApi.getMyUsage());
|
||||||
|
} catch {
|
||||||
|
/* 保持上次数据 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-2xl p-6 text-sm text-muted">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-cinnabar">
|
||||||
|
<CircleAlertIcon className="size-4" />
|
||||||
|
<span>神识阁数据加载失败</span>
|
||||||
|
</div>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTokens = usage?.rows.reduce(
|
||||||
|
(acc, r) => acc + r.prompt_tokens + r.completion_tokens,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* ═══ 生成参数 ═══ */}
|
||||||
|
<div className="set-card">
|
||||||
|
<div className="set-card-head">
|
||||||
|
<h3 className="set-card-title flex items-center gap-2">
|
||||||
|
<GaugeIcon className="size-4 text-patina" />
|
||||||
|
生成参数
|
||||||
|
</h3>
|
||||||
|
<span className="set-card-eng">Preferences</span>
|
||||||
|
</div>
|
||||||
|
<p className="set-card-desc">
|
||||||
|
默认逻辑模型与采样参数;实际路由与故障切换由神识决定。
|
||||||
|
</p>
|
||||||
|
{prefs ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="set-row block">
|
||||||
|
<div className="lab">
|
||||||
|
<div className="t">默认逻辑模型</div>
|
||||||
|
<div className="h">model_hint(逻辑模型名或能力槽位)</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={cn("ctrl", inputCls)}
|
||||||
|
value={prefs.default_model_hint}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrefs({ ...prefs, default_model_hint: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="set-row block">
|
||||||
|
<div className="lab">
|
||||||
|
<div className="t">采样温度</div>
|
||||||
|
<div className="h">temperature(0 – 2)</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
className={cn("ctrl", inputCls)}
|
||||||
|
value={prefs.temperature}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrefs({ ...prefs, temperature: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="set-row block">
|
||||||
|
<div className="lab">
|
||||||
|
<div className="t">核采样</div>
|
||||||
|
<div className="h">top_p(0 – 1)</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
className={cn("ctrl", inputCls)}
|
||||||
|
value={prefs.top_p}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrefs({ ...prefs, top_p: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="set-row block">
|
||||||
|
<div className="lab">
|
||||||
|
<div className="t">输出上限</div>
|
||||||
|
<div className="h">max_output_tokens</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={200000}
|
||||||
|
className={cn("ctrl", inputCls)}
|
||||||
|
value={prefs.max_output_tokens}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrefs({
|
||||||
|
...prefs,
|
||||||
|
max_output_tokens: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="set-row sm:col-span-2">
|
||||||
|
<div className="lab">
|
||||||
|
<div className="t">流式输出</div>
|
||||||
|
<div className="h">逐字回流(SSE)</div>
|
||||||
|
</div>
|
||||||
|
<div className="ctrl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={prefs.streaming}
|
||||||
|
onClick={() =>
|
||||||
|
setPrefs({ ...prefs, streaming: !prefs.streaming })
|
||||||
|
}
|
||||||
|
className={cn("toggle", prefs.streaming && "on")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveState === "saving"}
|
||||||
|
>
|
||||||
|
{saveState === "saving" ? "保存中" : "保存偏好"}
|
||||||
|
</Button>
|
||||||
|
{saveState === "saved" && (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-patina">
|
||||||
|
<CheckIcon className="size-4" /> 已保存
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<span className="text-sm text-cinnabar">{message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="set-card-desc">偏好数据不可用。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 自带密钥 ═══ */}
|
||||||
|
<div className="set-card">
|
||||||
|
<div className="set-card-head">
|
||||||
|
<h3 className="set-card-title flex items-center gap-2">
|
||||||
|
<KeyRoundIcon className="size-4 text-gold" />
|
||||||
|
自带密钥
|
||||||
|
</h3>
|
||||||
|
<span className="set-card-eng">BYOK</span>
|
||||||
|
</div>
|
||||||
|
<p className="set-card-desc">
|
||||||
|
登记你的 Provider
|
||||||
|
密钥,请求携带该密钥引用时独占路由(不回落平台端点)。
|
||||||
|
密钥加密存储,仅脱敏信息回显;撤回立即生效。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted">有效密钥 {keys.length} 把</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-1 size-4" />
|
||||||
|
{showForm ? "收起" : "登记密钥"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="mb-4 grid gap-3 rounded-xl border border-rule bg-surface p-4 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-muted">备注名</span>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="如:主力 DeepSeek"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-muted">端点类型</span>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => setForm({ ...form, type: e.target.value })}
|
||||||
|
>
|
||||||
|
{BYOK_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="mb-1 block text-xs text-muted">Base URL</span>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="https://api.deepseek.com"
|
||||||
|
value={form.base_url}
|
||||||
|
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-muted">
|
||||||
|
API Key(仅本次提交可见)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="sk-…"
|
||||||
|
value={form.api_key}
|
||||||
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-muted">上游模型名</span>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="deepseek-v4-flash"
|
||||||
|
value={form.model}
|
||||||
|
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
登记并加密保存
|
||||||
|
</Button>
|
||||||
|
{byokError && (
|
||||||
|
<span className="text-sm text-cinnabar">{byokError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keys.length === 0 && !showForm ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||||
|
尚未登记自带密钥;未登记时全部请求走平台端点。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-rule rounded-xl border border-rule">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<li key={k.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-serif text-sm text-fg">
|
||||||
|
{k.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
|
||||||
|
{k.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate font-mono text-xs text-muted">
|
||||||
|
{k.api_key_mask} · {k.model} · {k.base_url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={confirmDelete === k.id ? "danger" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevoke(k.id)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<TrashIcon className="mr-1 size-4" />
|
||||||
|
{confirmDelete === k.id ? "确认撤回" : "撤回"}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 用量 ═══ */}
|
||||||
|
<div className="set-card">
|
||||||
|
<div className="set-card-head">
|
||||||
|
<h3 className="set-card-title flex items-center gap-2">
|
||||||
|
<ChartColumnIcon className="size-4 text-patina" />
|
||||||
|
用量
|
||||||
|
</h3>
|
||||||
|
<span className="set-card-eng">Usage</span>
|
||||||
|
</div>
|
||||||
|
<p className="set-card-desc">
|
||||||
|
归属你的模型用量(用户 × 模型 × 端点);与 Provider
|
||||||
|
对账以原始记录为准。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{usage?.quota && usage.quota.budget > 0 && (
|
||||||
|
<div className="mb-3 rounded-xl border border-rule bg-surface p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>
|
||||||
|
配额{" "}
|
||||||
|
{(usage.quota.used + usage.quota.reserved).toLocaleString()} /{" "}
|
||||||
|
{usage.quota.budget.toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
预扣中 {usage.quota.reserved.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-rule">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-patina transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(
|
||||||
|
((usage.quota.used + usage.quota.reserved) /
|
||||||
|
usage.quota.budget) *
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs text-muted">
|
||||||
|
累计 {totalTokens?.toLocaleString() ?? 0} tokens
|
||||||
|
{usageAt && ` · ${usageAt.toLocaleTimeString("zh-CN")}`}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleRefreshUsage}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!usage || usage.rows.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||||
|
暂无归属用量记录。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-rule">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-rule bg-surface text-left text-xs text-muted">
|
||||||
|
<th className="px-3 py-2 font-normal">模型</th>
|
||||||
|
<th className="px-3 py-2 font-normal">端点</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">请求</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">成功</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">
|
||||||
|
输入 tokens
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">
|
||||||
|
输出 tokens
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-rule">
|
||||||
|
{usage.rows.map((r, i) => (
|
||||||
|
<tr
|
||||||
|
key={`${r.model}-${r.endpoint}-${i}`}
|
||||||
|
className="hover:bg-surface-hi"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{r.model || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-muted">
|
||||||
|
{r.endpoint || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{r.requests}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-patina">
|
||||||
|
{r.ok}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-muted">
|
||||||
|
{r.prompt_tokens.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-muted">
|
||||||
|
{r.completion_tokens.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -632,3 +632,214 @@ export function WuxingIcon(props: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 密钥(Lucide KeyRound) */
|
||||||
|
export function KeyRoundIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="16.5" cy="7.5" r="0.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表(Lucide Gauge) */
|
||||||
|
export function GaugeIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m12 14 4-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.34 19a10 10 0 1 1 17.32 0"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 柱状图(Lucide ChartColumnBig) */
|
||||||
|
export function ChartColumnIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3v18h18"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18 17V9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13 17V5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 17v-3"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加号(Lucide Plus) */
|
||||||
|
export function PlusIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5 12h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5v14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除(Lucide Trash2) */
|
||||||
|
export function TrashIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 6h18"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 11v6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 11v6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对勾(Lucide Check) */
|
||||||
|
export function CheckIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 6 9 17l-5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 警示(Lucide CircleAlert) */
|
||||||
|
export function CircleAlertIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M12 8v4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 16h.01"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export * as toolsApi from "./tools";
|
|||||||
export * as safetyApi from "./safety";
|
export * as safetyApi from "./safety";
|
||||||
export * as adminApi from "./admin";
|
export * as adminApi from "./admin";
|
||||||
export * as settingsApi from "./settings";
|
export * as settingsApi from "./settings";
|
||||||
|
export * as meApi from "./me";
|
||||||
export * as importsApi from "./imports";
|
export * as importsApi from "./imports";
|
||||||
export * as extractionApi from "./extraction";
|
export * as extractionApi from "./extraction";
|
||||||
export * as worldApi from "./world";
|
export * as worldApi from "./world";
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
apiDelete: vi.fn(),
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { apiDelete, apiGet, apiPost } from "./client";
|
||||||
|
import { getMyUsage, listByokKeys, registerByokKey, revokeByokKey } from "./me";
|
||||||
|
|
||||||
|
describe("me api(神识阁数据源)", () => {
|
||||||
|
it("listByokKeys GET /me/byok-keys", async () => {
|
||||||
|
vi.mocked(apiGet).mockResolvedValue({ keys: [] });
|
||||||
|
await listByokKeys();
|
||||||
|
expect(apiGet).toHaveBeenCalledWith("/me/byok-keys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registerByokKey POST 密钥登记", async () => {
|
||||||
|
vi.mocked(apiPost).mockResolvedValue({ id: "byok_1" });
|
||||||
|
await registerByokKey({
|
||||||
|
name: "主力",
|
||||||
|
type: "openai_compatible",
|
||||||
|
base_url: "https://api.deepseek.com",
|
||||||
|
api_key: "sk-secret",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
});
|
||||||
|
expect(apiPost).toHaveBeenCalledWith("/me/byok-keys", {
|
||||||
|
name: "主力",
|
||||||
|
type: "openai_compatible",
|
||||||
|
base_url: "https://api.deepseek.com",
|
||||||
|
api_key: "sk-secret",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokeByokKey DELETE 带 URI 编码", async () => {
|
||||||
|
vi.mocked(apiDelete).mockResolvedValue(undefined);
|
||||||
|
await revokeByokKey("byok/a b");
|
||||||
|
expect(apiDelete).toHaveBeenCalledWith("/me/byok-keys/byok%2Fa%20b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMyUsage GET /me/usage", async () => {
|
||||||
|
vi.mocked(apiGet).mockResolvedValue({ rows: [] });
|
||||||
|
await getMyUsage();
|
||||||
|
expect(apiGet).toHaveBeenCalledWith("/me/usage");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 用户面 API Client(神识阁数据源:BYOK 密钥与用量)。
|
||||||
|
*
|
||||||
|
* 密钥明文只在登记请求中出现,响应永远脱敏(byok api-key-design §3)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiDelete, apiGet, apiPost } from "./client";
|
||||||
|
import type { ByokKey, MyUsage, RegisterByokKeyRequest } from "@/types/api";
|
||||||
|
|
||||||
|
/** BYOK 密钥清单(GET /me/byok-keys,脱敏) */
|
||||||
|
export function listByokKeys() {
|
||||||
|
return apiGet<{ keys: ByokKey[] }>("/me/byok-keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登记自带密钥(POST /me/byok-keys) */
|
||||||
|
export function registerByokKey(body: RegisterByokKeyRequest) {
|
||||||
|
return apiPost<ByokKey>("/me/byok-keys", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤回密钥(DELETE /me/byok-keys/{id},立即失效) */
|
||||||
|
export function revokeByokKey(keyId: string) {
|
||||||
|
return apiDelete<void>(`/me/byok-keys/${encodeURIComponent(keyId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的用量(GET /me/usage:聚合行 + 配额总览) */
|
||||||
|
export function getMyUsage() {
|
||||||
|
return apiGet<MyUsage>("/me/usage");
|
||||||
|
}
|
||||||
@@ -82,6 +82,57 @@ export function resolveMock(
|
|||||||
// —— 健康检查 ——
|
// —— 健康检查 ——
|
||||||
if (m === "GET" && path === "/health") return ok(data.health);
|
if (m === "GET" && path === "/health") return ok(data.health);
|
||||||
|
|
||||||
|
// —— 神识阁:BYOK 密钥(脱敏)——
|
||||||
|
if (m === "GET" && path === "/me/byok-keys") {
|
||||||
|
return ok({
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
id: "byok_mock_001",
|
||||||
|
name: "主力 DeepSeek",
|
||||||
|
type: "openai_compatible",
|
||||||
|
base_url: "https://api.deepseek.com",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
api_key_mask: "sk-****",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (m === "POST" && path === "/me/byok-keys") {
|
||||||
|
const body = (json ?? {}) as Record<string, unknown>;
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
id: "byok_mock_002",
|
||||||
|
name: String(body.name ?? ""),
|
||||||
|
type: String(body.type ?? ""),
|
||||||
|
base_url: String(body.base_url ?? ""),
|
||||||
|
model: String(body.model ?? ""),
|
||||||
|
api_key_mask: "sk-****",
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (m === "DELETE" && path.startsWith("/me/byok-keys/")) {
|
||||||
|
return ok(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 神识阁:用量 ——
|
||||||
|
if (m === "GET" && path === "/me/usage") {
|
||||||
|
return ok({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
user_id: "user_mock",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
endpoint: "deepseek",
|
||||||
|
requests: 12,
|
||||||
|
ok: 11,
|
||||||
|
prompt_tokens: 8400,
|
||||||
|
completion_tokens: 3100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quota: { budget: 1_000_000, used: 11_500, reserved: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// —— 设置:生成参数偏好(GET / PATCH /settings/preferences)——
|
// —— 设置:生成参数偏好(GET / PATCH /settings/preferences)——
|
||||||
if (m === "GET" && path === "/settings/preferences") {
|
if (m === "GET" && path === "/settings/preferences") {
|
||||||
return ok(data.generationPreferences);
|
return ok(data.generationPreferences);
|
||||||
|
|||||||
@@ -655,6 +655,51 @@ export interface GenerationPreferences {
|
|||||||
/** 更新生成偏好(PATCH /settings/preferences) */
|
/** 更新生成偏好(PATCH /settings/preferences) */
|
||||||
export type UpdateGenerationPreferencesRequest = Partial<GenerationPreferences>;
|
export type UpdateGenerationPreferencesRequest = Partial<GenerationPreferences>;
|
||||||
|
|
||||||
|
/** BYOK 密钥脱敏形态(GET/POST /me/byok-keys;API 永不回明文) */
|
||||||
|
export interface ByokKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "openai_compatible" | "anthropic" | "gemini" | string;
|
||||||
|
base_url: string;
|
||||||
|
model: string;
|
||||||
|
api_key_mask: string;
|
||||||
|
created_at?: string;
|
||||||
|
last_used?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登记用户自带密钥(POST /me/byok-keys;明文仅本次请求可见,落库即加密) */
|
||||||
|
export interface RegisterByokKeyRequest {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
base_url: string;
|
||||||
|
api_key: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用量聚合行(用户×模型×端点,GET /me/usage) */
|
||||||
|
export interface UsageAggRow {
|
||||||
|
user_id?: string;
|
||||||
|
model: string;
|
||||||
|
endpoint: string;
|
||||||
|
requests: number;
|
||||||
|
ok: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配额总览(用户级软配额;budget=0 表示不限制) */
|
||||||
|
export interface UsageQuota {
|
||||||
|
budget: number;
|
||||||
|
used: number;
|
||||||
|
reserved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用量查询响应 */
|
||||||
|
export interface MyUsage {
|
||||||
|
rows: UsageAggRow[];
|
||||||
|
quota?: UsageQuota | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 工具权限策略(契约字典 §5.6) */
|
/** 工具权限策略(契约字典 §5.6) */
|
||||||
export type PermissionPolicy = "allow" | "ask" | "always_ask" | "forbidden";
|
export type PermissionPolicy = "allow" | "ask" | "always_ask" | "forbidden";
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -15,11 +15,15 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
shenshi/
|
shenshi/
|
||||||
main.go # 入口:配置与路由装配
|
main.go # 入口:store/byok/quota 装配与路由
|
||||||
internal/ir/types.go # IR 请求/增量(与灵台 app/shenshi_client/types.py 对齐)
|
internal/ir/types.go # IR 请求/增量(与灵台 app/shenshi_client/types.py 对齐)
|
||||||
internal/transform/ # 出站适配器(Adaptor 接口 + stub)
|
internal/transform/ # 出站适配器(openai 兼容 / anthropic / gemini / stub)
|
||||||
internal/route/registry.go # 槽位 → 端点候选注册表
|
internal/route/registry.go # 槽位 → 端点候选注册表
|
||||||
internal/relay/sse.go # SSE 帧写出(X-Accel-Buffering: no)
|
internal/relay/sse.go # SSE 帧写出(X-Accel-Buffering: no)
|
||||||
|
internal/store/ # 神识自有库(PG + 内存双实现)
|
||||||
|
internal/byok/ # BYOK 密钥库(AES-GCM 加密与脱敏)
|
||||||
|
internal/quota/ # 配额三段式(预扣/结算/退款,钳位集中)
|
||||||
|
internal/metering/ # 用量计量(stdout + 落库)
|
||||||
internal/server/ # HTTP 服务与测试
|
internal/server/ # HTTP 服务与测试
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -1,3 +1,12 @@
|
|||||||
module hunjing/shenshi
|
module hunjing/shenshi
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Package byok 用户自带密钥:AES-GCM 加密存储与脱敏呈现(shenshi-gateway.md §6)。
|
||||||
|
//
|
||||||
|
// 密钥分层:平台密钥走端点目录(SHENSHI_ENDPOINTS),用户 BYOK 走本包;
|
||||||
|
// 主密钥由 SHENSHI_BYOK_MASTER_KEY 提供(32 字节 hex/base64);未配置时
|
||||||
|
// BYOK 功能整体禁用(fail-closed,HJ-2003),不留明文降级路径。
|
||||||
|
package byok
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Masked API 呈现形态:永不回明文。
|
||||||
|
type Masked struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
|
// APIKeyMask 形如 sk-****abcd(前缀仅示意类型,不泄露中段)
|
||||||
|
APIKeyMask string `json:"api_key_mask"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault BYOK 密钥库。
|
||||||
|
type Vault struct {
|
||||||
|
aesgcm cipher.AEAD
|
||||||
|
store store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVault 构造;masterKey 为 32 字节 hex 或 base64。
|
||||||
|
func NewVault(masterKey string, st store.Store) (*Vault, error) {
|
||||||
|
if masterKey == "" {
|
||||||
|
return nil, fmt.Errorf("SHENSHI_BYOK_MASTER_KEY 未配置,BYOK 功能禁用")
|
||||||
|
}
|
||||||
|
key, err := decodeKey(masterKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Vault{aesgcm: gcm, store: st}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKey(raw string) ([]byte, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(raw); err == nil && len(b) == 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("SHENSHI_BYOK_MASTER_KEY 须为 32 字节 hex 或 base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) encrypt(plaintext string) (string, error) {
|
||||||
|
nonce := make([]byte, v.aesgcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sealed := v.aesgcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) decrypt(encoded string) (string, error) {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n := v.aesgcm.NonceSize()
|
||||||
|
if len(raw) <= n {
|
||||||
|
return "", fmt.Errorf("密文形态非法")
|
||||||
|
}
|
||||||
|
plain, err := v.aesgcm.Open(nil, raw[:n], raw[n:], nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("HJ-2003 BYOK 密钥解密失败")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 登记用户密钥,返回脱敏形态。
|
||||||
|
func (v *Vault) Register(userID, name, typ, baseURL, apiKey, model string) (*Masked, error) {
|
||||||
|
enc, err := v.encrypt(apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := store.ByokKey{
|
||||||
|
ID: newID(), UserID: userID, Name: name, Type: typ,
|
||||||
|
BaseURL: baseURL, APIKey: enc, Model: model, CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := v.store.UpsertByok(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := mask(key, apiKey)
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 脱敏列举用户有效密钥。
|
||||||
|
func (v *Vault) List(userID string) ([]Masked, error) {
|
||||||
|
keys, err := v.store.ListByok(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Masked, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, mask(k, ""))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve 取解密后的密钥材料(路由期专用,结果不落日志)。
|
||||||
|
func (v *Vault) Resolve(userID, id string) (*store.ByokKey, string, error) {
|
||||||
|
k, err := v.store.GetByok(userID, id)
|
||||||
|
if err != nil || k == nil {
|
||||||
|
return nil, "", fmt.Errorf("HJ-2003 BYOK 密钥不存在或已撤回")
|
||||||
|
}
|
||||||
|
plain, err := v.decrypt(k.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_ = v.store.TouchByok(id, now)
|
||||||
|
k.LastUsed = &now
|
||||||
|
return k, plain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 撤回(软删,立即失效)。
|
||||||
|
func (v *Vault) Revoke(userID, id string) error {
|
||||||
|
return v.store.RevokeByok(userID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mask(k store.ByokKey, plainForPrefix string) Masked {
|
||||||
|
prefix := ""
|
||||||
|
if plainForPrefix != "" {
|
||||||
|
prefix = plainForPrefix[:min(3, len(plainForPrefix))]
|
||||||
|
}
|
||||||
|
m := Masked{
|
||||||
|
ID: k.ID, Name: k.Name, Type: k.Type, BaseURL: k.BaseURL, Model: k.Model,
|
||||||
|
CreatedAt: k.CreatedAt, LastUsed: k.LastUsed,
|
||||||
|
APIKeyMask: prefix + "****",
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 10)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return "byok_" + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package byok
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testMaster() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(i)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultRoundTripAndMask(t *testing.T) {
|
||||||
|
v, err := NewVault(testMaster(), store.NewMem())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := v.Register("u1", "主力", "anthropic", "https://api.anthropic.com", "sk-ant--secret123", "claude-x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.APIKeyMask, "****") || strings.Contains(m.APIKeyMask, "secret") {
|
||||||
|
t.Fatalf("掩码泄露明文: %s", m.APIKeyMask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密往返
|
||||||
|
k, plain, err := v.Resolve("u1", m.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "sk-ant--secret123" || k.Model != "claude-x" {
|
||||||
|
t.Fatalf("往返失真: %s %s", plain, k.Model)
|
||||||
|
}
|
||||||
|
if k.LastUsed == nil {
|
||||||
|
t.Fatal("使用时间应被记录")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表脱敏
|
||||||
|
keys, _ := v.List("u1")
|
||||||
|
if len(keys) != 1 || strings.Contains(keys[0].APIKeyMask, "secret") {
|
||||||
|
t.Fatalf("列表脱敏失败: %+v", keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤回后失效
|
||||||
|
if err := v.Revoke("u1", m.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := v.Resolve("u1", m.ID); err == nil {
|
||||||
|
t.Fatal("撤回后仍可解析")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultFailClosedWithoutMasterKey(t *testing.T) {
|
||||||
|
if _, err := NewVault("", store.NewMem()); err == nil {
|
||||||
|
t.Fatal("未配置主密钥应拒绝启用(fail-closed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultRejectsBadMasterKey(t *testing.T) {
|
||||||
|
if _, err := NewVault("too-short", store.NewMem()); err == nil {
|
||||||
|
t.Fatal("非 32 字节主密钥应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultCrossUserIsolation(t *testing.T) {
|
||||||
|
v, _ := NewVault(testMaster(), store.NewMem())
|
||||||
|
m, _ := v.Register("u1", "k", "gemini", "https://gen.example.com", "AIza-key", "gemini-x")
|
||||||
|
if _, _, err := v.Resolve("u2", m.ID); err == nil {
|
||||||
|
t.Fatal("u2 不应解析 u1 的密钥")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,13 @@ type ChatRequest struct {
|
|||||||
RunID string `json:"run_id"`
|
RunID string `json:"run_id"`
|
||||||
TraceID string `json:"trace_id,omitempty"`
|
TraceID string `json:"trace_id,omitempty"`
|
||||||
CharacterID string `json:"character_id,omitempty"`
|
CharacterID string `json:"character_id,omitempty"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
// UserID 用量归属与配额裁决维度(灵台鉴权后注入;空则记为平台调用)
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
UserID string `json:"user_id,omitempty"`
|
||||||
Stream bool `json:"stream"`
|
// ByokKeyID 用户自带密钥引用(配置继承的最终解析结果,神识不自行决定)
|
||||||
|
ByokKeyID string `json:"byok_key_id,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delta 神识标准化流增量(与 Provider 方言无关)。
|
// Delta 神识标准化流增量(与 Provider 方言无关)。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package metering 用量计量:每次尝试一条记录(stdout 结构化日志 + 进程内聚合)。
|
// Package metering 用量计量:每次尝试一条记录(stdout 结构化日志 + 神识自有库落库)。
|
||||||
// 生产部署接 PG(shenshi-gateway.md §7);M4a 先以日志与内存聚合支撑 /me/usage 与对账抽样。
|
// 未配置 SHENSHI_DATABASE_URL 时退化为进程内环形缓冲(开发档),/me/usage 与对账抽样同语义。
|
||||||
package metering
|
package metering
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,52 +7,57 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attempt 一次端点尝试的计量记录。
|
// Attempt 一次端点尝试的计量记录(IR 维度齐全,user_id 为归属计量主键)。
|
||||||
type Attempt struct {
|
type Attempt struct {
|
||||||
RunID string `json:"run_id"`
|
ID string `json:"id"` // run_id#endpoint 序号(结算幂等键)
|
||||||
CharacterID string `json:"character_id,omitempty"`
|
RunID string `json:"run_id"`
|
||||||
Model string `json:"model"`
|
CharacterID string `json:"character_id,omitempty"`
|
||||||
Slot string `json:"slot"`
|
UserID string `json:"user_id,omitempty"`
|
||||||
Endpoint string `json:"endpoint"`
|
Model string `json:"model"`
|
||||||
Status string `json:"status"` // ok / error / first_byte_timeout
|
Slot string `json:"slot"`
|
||||||
ErrorCode string `json:"error_code,omitempty"`
|
Endpoint string `json:"endpoint"`
|
||||||
FirstTokenMS int64 `json:"first_token_ms"`
|
Status string `json:"status"` // ok / error / first_byte_timeout
|
||||||
TotalMS int64 `json:"total_ms"`
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
FirstTokenMS int64 `json:"first_token_ms"`
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
TotalMS int64 `json:"total_ms"`
|
||||||
At time.Time `json:"at"`
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageAgg /me/usage 聚合行。
|
// Log 计量器:stdout JSON 始终输出;持久化走 store(nil 时仅进程内有界环形)。
|
||||||
type UsageAgg struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
Requests int `json:"requests"`
|
|
||||||
OK int `json:"ok"`
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log 进程内计量器(有界环形,避免无界增长)。
|
|
||||||
type Log struct {
|
type Log struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
store store.Store
|
||||||
entries []Attempt
|
entries []Attempt
|
||||||
limit int
|
limit int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 构造计量器(保留最近 N 条)。
|
// New 构造计量器(st 为 nil 时进程内保留最近 N 条)。
|
||||||
func New(limit int) *Log {
|
func New(limit int, st store.Store) *Log {
|
||||||
return &Log{entries: make([]Attempt, 0, limit), limit: limit}
|
return &Log{store: st, entries: make([]Attempt, 0, limit), limit: limit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record 记录一次尝试:stdout JSON 日志 + 内存追加。
|
// Record 记录一次尝试:stdout JSON 日志(对账抽样基准)+ 落库。
|
||||||
func (l *Log) Record(a Attempt) {
|
func (l *Log) Record(a Attempt) {
|
||||||
a.At = time.Now().UTC()
|
a.At = time.Now().UTC()
|
||||||
if raw, err := json.Marshal(a); err == nil {
|
if raw, err := json.Marshal(a); err == nil {
|
||||||
log.Printf(`{"kind":"shenshi_usage",%s`, string(raw[1:]))
|
log.Printf(`{"kind":"shenshi_usage",%s`, string(raw[1:]))
|
||||||
}
|
}
|
||||||
|
if l.store != nil {
|
||||||
|
_ = l.store.InsertUsage(store.UsageLog{
|
||||||
|
ID: a.ID, RunID: a.RunID, CharacterID: a.CharacterID, UserID: a.UserID,
|
||||||
|
Model: a.Model, Slot: a.Slot, Endpoint: a.Endpoint,
|
||||||
|
Status: a.Status, ErrorCode: a.ErrorCode,
|
||||||
|
FirstTokenMS: a.FirstTokenMS, TotalMS: a.TotalMS,
|
||||||
|
PromptTokens: a.PromptTokens, CompletionTokens: a.CompletionTokens, At: a.At,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
if len(l.entries) >= l.limit {
|
if len(l.entries) >= l.limit {
|
||||||
@@ -61,16 +66,22 @@ func (l *Log) Record(a Attempt) {
|
|||||||
l.entries = append(l.entries, a)
|
l.entries = append(l.entries, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate 按 模型×端点 聚合。
|
// Aggregate 按 用户×模型×端点 聚合(userID 为空聚合全体;管理面语义)。
|
||||||
func (l *Log) Aggregate() []UsageAgg {
|
func (l *Log) Aggregate(userID string) ([]store.UsageAgg, error) {
|
||||||
|
if l.store != nil {
|
||||||
|
return l.store.AggregateUsage(userID)
|
||||||
|
}
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
agg := map[string]*UsageAgg{}
|
agg := map[string]*store.UsageAgg{}
|
||||||
for _, e := range l.entries {
|
for _, e := range l.entries {
|
||||||
key := e.Model + "|" + e.Endpoint
|
if userID != "" && e.UserID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := e.UserID + "|" + e.Model + "|" + e.Endpoint
|
||||||
row := agg[key]
|
row := agg[key]
|
||||||
if row == nil {
|
if row == nil {
|
||||||
row = &UsageAgg{Model: e.Model, Endpoint: e.Endpoint}
|
row = &store.UsageAgg{UserID: e.UserID, Model: e.Model, Endpoint: e.Endpoint}
|
||||||
agg[key] = row
|
agg[key] = row
|
||||||
}
|
}
|
||||||
row.Requests++
|
row.Requests++
|
||||||
@@ -80,9 +91,33 @@ func (l *Log) Aggregate() []UsageAgg {
|
|||||||
row.CompletionTokens += e.CompletionTokens
|
row.CompletionTokens += e.CompletionTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := make([]UsageAgg, 0, len(agg))
|
out := make([]store.UsageAgg, 0, len(agg))
|
||||||
for _, row := range agg {
|
for _, row := range agg {
|
||||||
out = append(out, *row)
|
out = append(out, *row)
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample 对账抽样:最近 N 条原始记录(与 Provider 账单比对)。
|
||||||
|
func (l *Log) Sample(limit int) ([]store.UsageLog, error) {
|
||||||
|
if l.store != nil {
|
||||||
|
return l.store.SampleUsage(limit)
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
n := limit
|
||||||
|
if n > len(l.entries) {
|
||||||
|
n = len(l.entries)
|
||||||
|
}
|
||||||
|
out := make([]store.UsageLog, n)
|
||||||
|
for i, e := range l.entries[len(l.entries)-n:] {
|
||||||
|
out[i] = store.UsageLog{
|
||||||
|
ID: e.ID, RunID: e.RunID, CharacterID: e.CharacterID, UserID: e.UserID,
|
||||||
|
Model: e.Model, Slot: e.Slot, Endpoint: e.Endpoint,
|
||||||
|
Status: e.Status, ErrorCode: e.ErrorCode,
|
||||||
|
FirstTokenMS: e.FirstTokenMS, TotalMS: e.TotalMS,
|
||||||
|
PromptTokens: e.PromptTokens, CompletionTokens: e.CompletionTokens, At: e.At,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package quota 用户级软配额:三段式(预扣 → 结算 → 退款,shenshi-gateway.md §7)。
|
||||||
|
//
|
||||||
|
// P0/P1 只做预算熔断(token 口径,HJ-2004 配额不足);1.0 官方云计费属 P2。
|
||||||
|
// 换算与钳位集中在本包单一函数,饱和截断防溢出变负。
|
||||||
|
package quota
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInsufficient 配额不足(HJ-2004)。
|
||||||
|
var ErrInsufficient = errors.New("HJ-2004 配额不足")
|
||||||
|
|
||||||
|
// Limiter 用户配额裁决器;budget=0 表示不限制(本地开发档)。
|
||||||
|
type Limiter struct {
|
||||||
|
store store.Store
|
||||||
|
budget int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 构造裁决器。
|
||||||
|
func New(st store.Store, budgetTokens int64) *Limiter {
|
||||||
|
return &Limiter{store: st, budget: budgetTokens}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp 集中单一换算函数:预扣额 = min(请求上限, 钳位上限),且饱和截断不为负。
|
||||||
|
// 请求侧任何计费乘数(max_tokens 等)在进入本函数前已由 IR 校验限幅。
|
||||||
|
func clamp(requested int64) int64 {
|
||||||
|
const reserveCeiling = 1 << 20 // 1M token 单次预扣上限(校验期设上限,防溢出)
|
||||||
|
if requested <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if requested > reserveCeiling {
|
||||||
|
return reserveCeiling
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve 预扣(按上限):配额不足时拒绝并返回 ErrInsufficient。
|
||||||
|
func (l *Limiter) Reserve(userID string, requested int64) (reserved int64, err error) {
|
||||||
|
reserved = clamp(requested)
|
||||||
|
if l.budget <= 0 || reserved <= 0 {
|
||||||
|
// 不限制档仍记账(可见性),但不裁决
|
||||||
|
if reserved > 0 {
|
||||||
|
return reserved, l.store.Reserve(userID, reserved)
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
b, err := l.store.Balance(userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if b.Reserved+b.Used+reserved > l.budget {
|
||||||
|
return 0, fmt.Errorf("%w:已用 %d + 预扣 %d + 本次 %d > 预算 %d",
|
||||||
|
ErrInsufficient, b.Used, b.Reserved, reserved, l.budget)
|
||||||
|
}
|
||||||
|
if err := l.store.Reserve(userID, reserved); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return reserved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle 结算:实际用量回填,差额(预扣-实际)即时释放。幂等性由 usage_logs
|
||||||
|
// 主键(同一尝试 ID 只落一条)保证:结算与落库同批执行,重放不会二次扣减。
|
||||||
|
func (l *Limiter) Settle(userID string, reserved, actual int64) error {
|
||||||
|
return l.store.Settle(userID, reserved, clamp(actual))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund 失败退款:全额释放预扣(幂等——释放至多为预扣额,GREATEST 饱和截断)。
|
||||||
|
func (l *Limiter) Refund(userID string, reserved int64) error {
|
||||||
|
if reserved <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l.store.Refund(userID, reserved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget 预算与余额总览(/me/usage 附带)。
|
||||||
|
func (l *Limiter) Budget(userID string) (budget, used, reserved int64, err error) {
|
||||||
|
b, err := l.store.Balance(userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
return l.budget, b.Used, b.Reserved, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package quota
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReserveRejectsWhenBudgetExhausted(t *testing.T) {
|
||||||
|
st := store.NewMem()
|
||||||
|
l := New(st, 1000)
|
||||||
|
if _, err := l.Reserve("u1", 600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Reserve("u1", 600); err == nil {
|
||||||
|
t.Fatal("累计超预算应拒绝(HJ-2004)")
|
||||||
|
} else if !errors.Is(err, ErrInsufficient) {
|
||||||
|
t.Fatalf("错误类型错误: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettleReleasesAndAllowsReuse(t *testing.T) {
|
||||||
|
st := store.NewMem()
|
||||||
|
l := New(st, 1000)
|
||||||
|
r, _ := l.Reserve("u1", 600)
|
||||||
|
if err := l.Settle("u1", r, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 已用 100,剩 900 可用
|
||||||
|
if _, err := l.Reserve("u1", 900); err != nil {
|
||||||
|
t.Fatalf("结算释放差值后应可继续使用: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefundIdempotent(t *testing.T) {
|
||||||
|
st := store.NewMem()
|
||||||
|
l := New(st, 1000)
|
||||||
|
r, _ := l.Reserve("u1", 500)
|
||||||
|
_ = l.Refund("u1", r)
|
||||||
|
_ = l.Refund("u1", r) // 幂等重放
|
||||||
|
budget, used, reserved, _ := l.Budget("u1")
|
||||||
|
if reserved != 0 || used != 0 || budget != 1000 {
|
||||||
|
t.Fatalf("重复退款后账目异常: budget=%d used=%d reserved=%d", budget, used, reserved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClampCeiling(t *testing.T) {
|
||||||
|
if got := clamp(1 << 30); got != 1<<20 {
|
||||||
|
t.Fatalf("超大预扣应钳位到 1M,实际 %d", got)
|
||||||
|
}
|
||||||
|
if got := clamp(-5); got != 0 {
|
||||||
|
t.Fatalf("负值应截断为 0,实际 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroBudgetMeansUnlimited(t *testing.T) {
|
||||||
|
st := store.NewMem()
|
||||||
|
l := New(st, 0)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := l.Reserve("u1", 1<<20); err != nil {
|
||||||
|
t.Fatalf("budget=0 不应裁决: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,19 +54,14 @@ func New() *Registry {
|
|||||||
}
|
}
|
||||||
specs := loadSpecs()
|
specs := loadSpecs()
|
||||||
if len(specs) == 0 {
|
if len(specs) == 0 {
|
||||||
r.Register("chat", Endpoint{Type: "stub", Adaptor: transform.StubAdaptor{}, Priority: 100, UpstreamModel: "stub"})
|
r.Register("chat", Endpoint{Name: "stub", Type: "stub", Adaptor: transform.StubAdaptor{}, Priority: 100, UpstreamModel: "stub"})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
if spec.Type != "openai_compatible" && spec.Type != "local" {
|
adaptor, ok := BuildAdaptor(spec)
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
adaptor := &transform.OpenAICompatAdaptor{
|
|
||||||
EndpointName: spec.Name,
|
|
||||||
BaseURL: spec.BaseURL,
|
|
||||||
APIKey: spec.APIKey,
|
|
||||||
UpstreamModel: spec.Model,
|
|
||||||
}
|
|
||||||
cred := spec.Name
|
cred := spec.Name
|
||||||
if spec.APIKey != "" {
|
if spec.APIKey != "" {
|
||||||
cred = spec.Name + ":" + fmt.Sprintf("%x", len(spec.APIKey)) // 不落密钥原文,仅指纹
|
cred = spec.Name + ":" + fmt.Sprintf("%x", len(spec.APIKey)) // 不落密钥原文,仅指纹
|
||||||
@@ -79,6 +74,29 @@ func New() *Registry {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildAdaptor 按端点类型构造出站适配器(契约词典 §14.2;BYOK 动态候选复用)。
|
||||||
|
func BuildAdaptor(spec EndpointSpec) (transform.Adaptor, bool) {
|
||||||
|
switch spec.Type {
|
||||||
|
case "openai_compatible", "local":
|
||||||
|
return &transform.OpenAICompatAdaptor{
|
||||||
|
EndpointName: spec.Name, BaseURL: spec.BaseURL,
|
||||||
|
APIKey: spec.APIKey, UpstreamModel: spec.Model,
|
||||||
|
}, true
|
||||||
|
case "anthropic":
|
||||||
|
return &transform.AnthropicAdaptor{
|
||||||
|
EndpointName: spec.Name, BaseURL: spec.BaseURL,
|
||||||
|
APIKey: spec.APIKey, UpstreamModel: spec.Model,
|
||||||
|
}, true
|
||||||
|
case "gemini":
|
||||||
|
return &transform.GeminiAdaptor{
|
||||||
|
EndpointName: spec.Name, BaseURL: spec.BaseURL,
|
||||||
|
APIKey: spec.APIKey, UpstreamModel: spec.Model,
|
||||||
|
}, true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadSpecs() []EndpointSpec {
|
func loadSpecs() []EndpointSpec {
|
||||||
raw := os.Getenv("SHENSHI_ENDPOINTS")
|
raw := os.Getenv("SHENSHI_ENDPOINTS")
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// BYOK / 配额三段式 / 用量归属的服务级场景测试。
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/byok"
|
||||||
|
"hunjing/shenshi/internal/quota"
|
||||||
|
"hunjing/shenshi/internal/route"
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testMasterKey() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullServer 装配 store/byok/quota 的完整服务;eps 为注册表候选(按优先级升序尝试)。
|
||||||
|
func fullServer(t *testing.T, budget int64, eps ...route.Endpoint) (*httptest.Server, store.Store, *byok.Vault) {
|
||||||
|
t.Helper()
|
||||||
|
st := store.NewMem()
|
||||||
|
vault, err := byok.NewVault(testMasterKey(), st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
registry := registryWith(t, time.Second, eps...)
|
||||||
|
srv := New(Config{Store: st, Vault: vault, Quota: quota.New(st, budget)}, registry)
|
||||||
|
ts := httptest.NewServer(srv)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return ts, st, vault
|
||||||
|
}
|
||||||
|
|
||||||
|
func postChat(ts *httptest.Server, body, userID string) (*http.Response, string) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/chat/completions", strings.NewReader(body))
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
if userID != "" {
|
||||||
|
req.Header.Set("X-Hunjing-User-Id", userID)
|
||||||
|
}
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
sb.WriteString(scanner.Text())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return resp, sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func byokChatBody(run, extra string) string {
|
||||||
|
return `{"ir_version":1,"slot":"chat","messages":[{"role":"user","content":"hi"}],"run_id":"` + run + `"` + extra + `,"stream":true}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景:BYOK 独占路由——请求携带 byok_key_id 时只走用户端点,不回落平台候选。
|
||||||
|
func TestByokExclusiveRouting(t *testing.T) {
|
||||||
|
platform := fakeOpenAI(t, 0, 0, "平台端点")
|
||||||
|
defer platform.Close()
|
||||||
|
ts, _, vault := fullServer(t, 0, oaEndpoint("platform", platform.URL, 10))
|
||||||
|
|
||||||
|
byokUpstream := fakeOpenAI(t, 0, 0, "用户端点")
|
||||||
|
defer byokUpstream.Close()
|
||||||
|
key, err := vault.Register("u1", "自带", "openai_compatible", byokUpstream.URL, "sk-mine", "my-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := byokChatBody("run_byok", `,"user_id":"u1","byok_key_id":"`+key.ID+`"`)
|
||||||
|
resp, out := postChat(ts, body, "u1")
|
||||||
|
if resp == nil || resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("BYOK 请求失败: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "用户端点") || strings.Contains(out, "平台端点") {
|
||||||
|
t.Fatalf("应独占 BYOK 端点: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景:BYOK 无效引用 → 整体失败,不回落平台端点。
|
||||||
|
func TestByokInvalidReferenceRejected(t *testing.T) {
|
||||||
|
platform := fakeOpenAI(t, 0, 0, "平台端点")
|
||||||
|
defer platform.Close()
|
||||||
|
ts, _, _ := fullServer(t, 0, oaEndpoint("platform", platform.URL, 10))
|
||||||
|
|
||||||
|
resp, out := postChat(ts, byokChatBody("run_bad", `,"user_id":"u1","byok_key_id":"byok_missing"`), "u1")
|
||||||
|
if resp == nil || resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("应 503: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "HJ-2001") || strings.Contains(out, "平台端点") {
|
||||||
|
t.Fatalf("应整体失败而非回落: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景:配额不足 → HJ-2004 预拒,且不产生用量记录。
|
||||||
|
func TestQuotaRejectsWhenExhausted(t *testing.T) {
|
||||||
|
upstream := fakeOpenAI(t, 0, 0, "内容")
|
||||||
|
defer upstream.Close()
|
||||||
|
ts, st, _ := fullServer(t, 10, oaEndpoint("up", upstream.URL, 10))
|
||||||
|
|
||||||
|
resp, out := postChat(ts, byokChatBody("run_q", `,"user_id":"u1","max_tokens":100`), "u1")
|
||||||
|
if resp == nil || resp.StatusCode != http.StatusPaymentRequired {
|
||||||
|
t.Fatalf("配额不足应 402: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "HJ-2004") {
|
||||||
|
t.Fatalf("应携带 HJ-2004: %s", out)
|
||||||
|
}
|
||||||
|
sample, _ := st.SampleUsage(10)
|
||||||
|
if len(sample) != 0 {
|
||||||
|
t.Fatalf("预拒请求不应产生用量记录: %+v", sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景:成功结算释放差值;全败全额退款——余额不残留。
|
||||||
|
func TestQuotaSettleAndRefundBalance(t *testing.T) {
|
||||||
|
upstream := fakeOpenAI(t, 0, 0, "内容")
|
||||||
|
bad := fakeOpenAI(t, 0, http.StatusForbidden, "")
|
||||||
|
defer upstream.Close()
|
||||||
|
defer bad.Close()
|
||||||
|
ts, st, _ := fullServer(t, 1000, oaEndpoint("up", upstream.URL, 10))
|
||||||
|
|
||||||
|
// 成功:预扣 max_tokens=200,实际 3+4=7
|
||||||
|
if resp, out := postChat(ts, byokChatBody("run_ok", `,"user_id":"u1","max_tokens":200`), "u1"); resp == nil || resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("成功请求失败: %s", out)
|
||||||
|
}
|
||||||
|
b, _ := st.Balance("u1")
|
||||||
|
if b.Used != 7 || b.Reserved != 0 {
|
||||||
|
t.Fatalf("结算后账目错误: %+v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已用 7,预算 1000:请求 max_tokens 994 应被拒(7+994 > 1000)
|
||||||
|
if resp, out := postChat(ts, byokChatBody("run_over", `,"user_id":"u1","max_tokens":994`), "u1"); resp == nil || resp.StatusCode != http.StatusPaymentRequired {
|
||||||
|
t.Fatalf("超预算应拒: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全候选失败 → 全额退款
|
||||||
|
tsBad, stBad, _ := fullServer(t, 1000, oaEndpoint("bad", bad.URL, 10))
|
||||||
|
if resp, out := postChat(tsBad, byokChatBody("run_af", `,"user_id":"u2","max_tokens":500`), "u2"); resp == nil || resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("全败应 503: %s", out)
|
||||||
|
}
|
||||||
|
b2, _ := stBad.Balance("u2")
|
||||||
|
if b2.Reserved != 0 || b2.Used != 0 {
|
||||||
|
t.Fatalf("全败后应全额退款: %+v", b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景:用量归属计量——/me/usage 按用户过滤并附配额总览。
|
||||||
|
func TestUsageAttributionByUser(t *testing.T) {
|
||||||
|
upstream := fakeOpenAI(t, 0, 0, "内容")
|
||||||
|
defer upstream.Close()
|
||||||
|
ts, _, _ := fullServer(t, 0, oaEndpoint("up", upstream.URL, 10))
|
||||||
|
|
||||||
|
for _, b := range []string{
|
||||||
|
byokChatBody("run_u1", `,"user_id":"u1"`),
|
||||||
|
byokChatBody("run_u2", `,"user_id":"u2"`),
|
||||||
|
byokChatBody("run_u1b", `,"user_id":"u1"`),
|
||||||
|
} {
|
||||||
|
if resp, out := postChat(ts, b, ""); resp == nil || resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("请求失败: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/me/usage", nil)
|
||||||
|
req.Header.Set("X-Hunjing-User-Id", "u1")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Rows []struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
} `json:"rows"`
|
||||||
|
Quota *struct {
|
||||||
|
Budget int64 `json:"budget"`
|
||||||
|
Used int64 `json:"used"`
|
||||||
|
} `json:"quota"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&payload)
|
||||||
|
if len(payload.Rows) != 1 || payload.Rows[0].UserID != "u1" || payload.Rows[0].Requests != 2 {
|
||||||
|
t.Fatalf("u1 聚合错误: %+v", payload.Rows)
|
||||||
|
}
|
||||||
|
if payload.Quota == nil || payload.Quota.Used != 14 {
|
||||||
|
t.Fatalf("配额总览错误: %+v", payload.Quota)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,12 +150,14 @@ func TestUsageAggregate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var agg []map[string]any
|
var payload struct {
|
||||||
|
Rows []map[string]any `json:"rows"`
|
||||||
|
}
|
||||||
defer func() { _ = usageResp.Body.Close() }()
|
defer func() { _ = usageResp.Body.Close() }()
|
||||||
if err := json.NewDecoder(usageResp.Body).Decode(&agg); err != nil {
|
if err := json.NewDecoder(usageResp.Body).Decode(&payload); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(agg) != 1 || agg[0]["ok"].(float64) != 1 || agg[0]["prompt_tokens"].(float64) != 3 {
|
if len(payload.Rows) != 1 || payload.Rows[0]["ok"].(float64) != 1 || payload.Rows[0]["prompt_tokens"].(float64) != 3 {
|
||||||
t.Fatalf("聚合不符:%v", agg)
|
t.Fatalf("聚合不符:%v", payload.Rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,36 @@
|
|||||||
// Package server 神识 HTTP 服务:数据面(IR 入站 → SSE 出站)、健康面与用量面。
|
// Package server 神识 HTTP 服务:数据面(IR 入站 → SSE 出站)、健康面、用量面与 BYOK 面。
|
||||||
//
|
//
|
||||||
// 故障切换红线:首帧到达前不提交 SSE 响应——失败可整体换道并返回 JSON 错误;
|
// 故障切换红线:首帧到达前不提交 SSE 响应——失败可整体换道并返回 JSON 错误;
|
||||||
// 已写出字节后不再换道,如实以 error 终帧收尾(shenshi-gateway.md §4/§5)。
|
// 已写出字节后不再换道,如实以 error 终帧收尾(shenshi-gateway.md §4/§5)。
|
||||||
|
//
|
||||||
|
// 配额三段式:预扣(max_tokens 上限,钳位)→ 结算(实际 token)→ 退款(失败幂等)。
|
||||||
|
// BYOK:请求携带 byok_key_id 时独占候选(不回落平台端点,防止用户预期自带密钥却被平台计费)。
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/byok"
|
||||||
"hunjing/shenshi/internal/ir"
|
"hunjing/shenshi/internal/ir"
|
||||||
"hunjing/shenshi/internal/metering"
|
"hunjing/shenshi/internal/metering"
|
||||||
|
"hunjing/shenshi/internal/quota"
|
||||||
"hunjing/shenshi/internal/relay"
|
"hunjing/shenshi/internal/relay"
|
||||||
"hunjing/shenshi/internal/route"
|
"hunjing/shenshi/internal/route"
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
"hunjing/shenshi/internal/transform"
|
"hunjing/shenshi/internal/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 服务配置。
|
// Config 服务配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceToken string
|
ServiceToken string
|
||||||
|
Store store.Store // nil 时计量退化为进程内环形
|
||||||
|
Vault *byok.Vault // nil 时 BYOK 面整体 503(fail-closed)
|
||||||
|
Quota *quota.Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server 神识 HTTP 服务。
|
// Server 神识 HTTP 服务。
|
||||||
@@ -32,11 +43,19 @@ type Server struct {
|
|||||||
|
|
||||||
// New 构建服务并注册路由。
|
// New 构建服务并注册路由。
|
||||||
func New(cfg Config, routes *route.Registry) *Server {
|
func New(cfg Config, routes *route.Registry) *Server {
|
||||||
s := &Server{cfg: cfg, routes: routes, usage: metering.New(10_000), mux: http.NewServeMux()}
|
s := &Server{
|
||||||
|
cfg: cfg, routes: routes,
|
||||||
|
usage: metering.New(10_000, cfg.Store),
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /readyz", s.handleHealth)
|
s.mux.HandleFunc("GET /readyz", s.handleHealth)
|
||||||
s.mux.Handle("POST /v1/chat/completions", s.auth(http.HandlerFunc(s.handleChat)))
|
s.mux.Handle("POST /v1/chat/completions", s.auth(http.HandlerFunc(s.handleChat)))
|
||||||
s.mux.Handle("GET /me/usage", s.auth(http.HandlerFunc(s.handleUsage)))
|
s.mux.Handle("GET /me/usage", s.auth(http.HandlerFunc(s.handleUsage)))
|
||||||
|
s.mux.Handle("GET /admin/usage/sample", s.auth(http.HandlerFunc(s.handleUsageSample)))
|
||||||
|
s.mux.Handle("GET /me/byok-keys", s.auth(http.HandlerFunc(s.handleByokList)))
|
||||||
|
s.mux.Handle("POST /me/byok-keys", s.auth(http.HandlerFunc(s.handleByokRegister)))
|
||||||
|
s.mux.Handle("DELETE /me/byok-keys/{id}", s.auth(http.HandlerFunc(s.handleByokRevoke)))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +77,10 @@ func (s *Server) auth(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleChat 数据面:IR → 候选迭代(首字超时/冷却标记/粘性)→ SSE。
|
// defaultReserve 预扣默认上限(max_tokens 未指定时按 4K 输出预扣)。
|
||||||
|
const defaultReserve = 4096
|
||||||
|
|
||||||
|
// handleChat 数据面:配额预扣 → 候选迭代(首字超时/冷却标记/粘性/BYOK 独占)→ SSE → 结算/退款。
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
var req ir.ChatRequest
|
var req ir.ChatRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -69,20 +91,37 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "HJ-2002", err.Error())
|
writeError(w, http.StatusBadRequest, "HJ-2002", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
candidates, err := s.routes.Candidates(req.Slot, req.RunID)
|
|
||||||
|
// 三段式之一:预扣(按输出上限;钳位集中在 quota 包)
|
||||||
|
reserveTokens := int64(defaultReserve)
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
reserveTokens = int64(*req.MaxTokens)
|
||||||
|
}
|
||||||
|
reserved, err := s.quotaReserve(req.UserID, reserveTokens)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
writeError(w, http.StatusPaymentRequired, "HJ-2004", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := s.candidates(&req)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.quotaRefund(req.UserID, reserved)
|
||||||
writeError(w, http.StatusServiceUnavailable, "HJ-2001", err.Error())
|
writeError(w, http.StatusServiceUnavailable, "HJ-2001", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, endpoint := range candidates {
|
for i, endpoint := range candidates {
|
||||||
res := s.tryEndpoint(w, r, endpoint, &req)
|
res := s.tryEndpoint(w, r, endpoint, &req, i)
|
||||||
s.usage.Record(res.Attempt)
|
s.usage.Record(res.Attempt)
|
||||||
if res.Committed {
|
if res.Committed {
|
||||||
// 已向客户端写出:无论成败都不能再换道
|
// 已向客户端写出:无论成败都不能再换道
|
||||||
if res.Status == "ok" {
|
if res.Status == "ok" {
|
||||||
s.routes.Sticky.Remember(req.RunID, endpoint.Name)
|
s.routes.Sticky.Remember(req.RunID, endpoint.Name)
|
||||||
|
s.quotaSettle(req.UserID, reserved, res.Attempt.PromptTokens+res.Attempt.CompletionTokens)
|
||||||
|
} else {
|
||||||
|
// 流中失败:已发生的 token 照实结算,其余退款
|
||||||
|
s.quotaSettle(req.UserID, reserved, res.Attempt.PromptTokens+res.Attempt.CompletionTokens)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,9 +137,55 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
lastErr = res.Err
|
lastErr = res.Err
|
||||||
}
|
}
|
||||||
|
// 三段式之三:全部候选失败 → 全额退款
|
||||||
|
_ = s.quotaRefund(req.UserID, reserved)
|
||||||
writeError(w, http.StatusServiceUnavailable, "HJ-2001", "全部候选端点失败:"+errString(lastErr))
|
writeError(w, http.StatusServiceUnavailable, "HJ-2001", "全部候选端点失败:"+errString(lastErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// candidates 候选序列:BYOK 独占(不回落平台端点);否则注册表候选。
|
||||||
|
func (s *Server) candidates(req *ir.ChatRequest) ([]route.Endpoint, error) {
|
||||||
|
if req.ByokKeyID == "" {
|
||||||
|
return s.routes.Candidates(req.Slot, req.RunID)
|
||||||
|
}
|
||||||
|
if s.cfg.Vault == nil {
|
||||||
|
return nil, fmt.Errorf("HJ-2003 BYOK 功能未启用(SHENSHI_BYOK_MASTER_KEY 未配置)")
|
||||||
|
}
|
||||||
|
key, plain, err := s.cfg.Vault.Resolve(req.UserID, req.ByokKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spec := route.EndpointSpec{Type: key.Type, Name: "byok:" + key.ID, BaseURL: key.BaseURL, APIKey: plain, Model: key.Model}
|
||||||
|
adaptor, ok := route.BuildAdaptor(spec)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("HJ-2003 BYOK 密钥类型不受支持: %s", key.Type)
|
||||||
|
}
|
||||||
|
return []route.Endpoint{{
|
||||||
|
Name: spec.Name, Type: key.Type, Adaptor: adaptor,
|
||||||
|
Priority: -100, CredentialKey: spec.Name, UpstreamModel: key.Model,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) quotaReserve(userID string, tokens int64) (int64, error) {
|
||||||
|
if s.cfg.Quota == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return s.cfg.Quota.Reserve(userID, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) quotaSettle(userID string, reserved int64, actual int) error {
|
||||||
|
if s.cfg.Quota == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.cfg.Quota.Settle(userID, reserved, int64(actual))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) quotaRefund(userID string, reserved int64) error {
|
||||||
|
if s.cfg.Quota == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.cfg.Quota.Refund(userID, reserved)
|
||||||
|
}
|
||||||
|
|
||||||
type attempt struct {
|
type attempt struct {
|
||||||
metering.Attempt
|
metering.Attempt
|
||||||
Committed bool // 是否已向客户端写出 SSE
|
Committed bool // 是否已向客户端写出 SSE
|
||||||
@@ -116,7 +201,7 @@ func errString(err error) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tryEndpoint 单端点尝试。
|
// tryEndpoint 单端点尝试。
|
||||||
func (s *Server) tryEndpoint(w http.ResponseWriter, r *http.Request, endpoint route.Endpoint, req *ir.ChatRequest) attempt {
|
func (s *Server) tryEndpoint(w http.ResponseWriter, r *http.Request, endpoint route.Endpoint, req *ir.ChatRequest, seq int) attempt {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), s.routes.FirstByteTimeout)
|
ctx, cancel := context.WithTimeout(r.Context(), s.routes.FirstByteTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -129,7 +214,8 @@ func (s *Server) tryEndpoint(w http.ResponseWriter, r *http.Request, endpoint ro
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
res := attempt{Attempt: metering.Attempt{
|
res := attempt{Attempt: metering.Attempt{
|
||||||
RunID: req.RunID, CharacterID: req.CharacterID,
|
ID: fmt.Sprintf("%s#%s#%d", req.RunID, endpoint.Name, seq),
|
||||||
|
RunID: req.RunID, CharacterID: req.CharacterID, UserID: req.UserID,
|
||||||
Model: req.Model, Slot: req.Slot, Endpoint: endpoint.Name,
|
Model: req.Model, Slot: req.Slot, Endpoint: endpoint.Name,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -195,10 +281,112 @@ func failStatus(err error) string {
|
|||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUsage 用量聚合(/me/usage;BYOK 归属维度随 M4b 接入)。
|
// meUser 从代理头取用户标识(灵台鉴权后注入,服务令牌即信任边界)。
|
||||||
func (s *Server) handleUsage(w http.ResponseWriter, _ *http.Request) {
|
func meUser(r *http.Request) string { return r.Header.Get("X-Hunjing-User-Id") }
|
||||||
|
|
||||||
|
// handleUsage 用户用量聚合(/me/usage;附配额余额)。
|
||||||
|
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := meUser(r)
|
||||||
|
rows, err := s.usage.Aggregate(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "HJ-2001", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{"rows": rows}
|
||||||
|
if s.cfg.Quota != nil {
|
||||||
|
budget, used, reserved, err := s.cfg.Quota.Budget(userID)
|
||||||
|
if err == nil {
|
||||||
|
resp["quota"] = map[string]int64{"budget": budget, "used": used, "reserved": reserved}
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(s.usage.Aggregate())
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUsageSample 对账抽样(/admin/usage/sample?limit=N;最近 N 条原始记录)。
|
||||||
|
func (s *Server) handleUsageSample(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit := 50
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if _, err := fmt.Sscanf(v, "%d", &limit); err != nil || limit <= 0 || limit > 1000 {
|
||||||
|
writeError(w, http.StatusBadRequest, "HJ-2002", "limit 须为 1..1000")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := s.usage.Sample(limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "HJ-2001", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"rows": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleByokList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.cfg.Vault == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "HJ-2003", "BYOK 功能未启用(SHENSHI_BYOK_MASTER_KEY 未配置)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys, err := s.cfg.Vault.List(meUser(r))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "HJ-2001", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
type byokRegisterRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"` // openai_compatible / anthropic / gemini
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleByokRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.cfg.Vault == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "HJ-2003", "BYOK 功能未启用(SHENSHI_BYOK_MASTER_KEY 未配置)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body byokRegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "HJ-2002", "请求解析失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch body.Type {
|
||||||
|
case "openai_compatible", "anthropic", "gemini":
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "HJ-2002", "type 须为 openai_compatible / anthropic / gemini")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.APIKey == "" || body.BaseURL == "" || body.Model == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "HJ-2002", "api_key / base_url / model 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := s.cfg.Vault.Register(meUser(r), body.Name, body.Type, body.BaseURL, body.APIKey, body.Model)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "HJ-2003", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleByokRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.cfg.Vault == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "HJ-2003", "BYOK 功能未启用(SHENSHI_BYOK_MASTER_KEY 未配置)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.cfg.Vault.Revoke(meUser(r), r.PathValue("id")); err != nil {
|
||||||
|
if errors.Is(err, store.ErrByokNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "HJ-2003", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "HJ-2001", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHealth 健康面。
|
// handleHealth 健康面。
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// PG 实现:与内存实现同语义的直写 SQL。
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) InsertUsage(l UsageLog) error {
|
||||||
|
_, err := p.db.Exec(
|
||||||
|
`INSERT INTO usage_logs (id, run_id, character_id, user_id, model, slot, endpoint,
|
||||||
|
status, error_code, first_token_ms, total_ms, prompt_tokens, completion_tokens, at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||||
|
l.ID, l.RunID, l.CharacterID, l.UserID, l.Model, l.Slot, l.Endpoint,
|
||||||
|
l.Status, l.ErrorCode, l.FirstTokenMS, l.TotalMS, l.PromptTokens, l.CompletionTokens, l.At,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) AggregateUsage(userID string) ([]UsageAgg, error) {
|
||||||
|
q := `SELECT user_id, model, endpoint, COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE status = 'ok'),
|
||||||
|
COALESCE(SUM(prompt_tokens) FILTER (WHERE status = 'ok'), 0),
|
||||||
|
COALESCE(SUM(completion_tokens) FILTER (WHERE status = 'ok'), 0)
|
||||||
|
FROM usage_logs`
|
||||||
|
args := []any{}
|
||||||
|
if userID != "" {
|
||||||
|
q += ` WHERE user_id = $1`
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
q += ` GROUP BY user_id, model, endpoint ORDER BY user_id, model`
|
||||||
|
rows, err := p.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
var out []UsageAgg
|
||||||
|
for rows.Next() {
|
||||||
|
var r UsageAgg
|
||||||
|
if err := rows.Scan(&r.UserID, &r.Model, &r.Endpoint, &r.Requests, &r.OK, &r.PromptTokens, &r.CompletionTokens); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) SampleUsage(limit int) ([]UsageLog, error) {
|
||||||
|
rows, err := p.db.Query(
|
||||||
|
`SELECT id, run_id, character_id, user_id, model, slot, endpoint, status, error_code,
|
||||||
|
first_token_ms, total_ms, prompt_tokens, completion_tokens, at
|
||||||
|
FROM usage_logs ORDER BY at DESC LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
var out []UsageLog
|
||||||
|
for rows.Next() {
|
||||||
|
var l UsageLog
|
||||||
|
if err := rows.Scan(&l.ID, &l.RunID, &l.CharacterID, &l.UserID, &l.Model, &l.Slot, &l.Endpoint,
|
||||||
|
&l.Status, &l.ErrorCode, &l.FirstTokenMS, &l.TotalMS, &l.PromptTokens, &l.CompletionTokens, &l.At); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) UpsertByok(k ByokKey) error {
|
||||||
|
_, err := p.db.Exec(
|
||||||
|
`INSERT INTO byok_keys (id, user_id, name, type, base_url, api_key, model, created_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name=$3, type=$4, base_url=$5, api_key=$6, model=$7`,
|
||||||
|
k.ID, k.UserID, k.Name, k.Type, k.BaseURL, k.APIKey, k.Model, k.CreatedAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) ListByok(userID string) ([]ByokKey, error) {
|
||||||
|
return p.byokQuery(`SELECT id, user_id, name, type, base_url, api_key, model, created_at, last_used, revoked_at
|
||||||
|
FROM byok_keys WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at`, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) GetByok(userID, id string) (*ByokKey, error) {
|
||||||
|
keys, err := p.byokQuery(`SELECT id, user_id, name, type, base_url, api_key, model, created_at, last_used, revoked_at
|
||||||
|
FROM byok_keys WHERE user_id = $1 AND id = $2 AND revoked_at IS NULL`, userID, id)
|
||||||
|
if err != nil || len(keys) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &keys[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) byokQuery(q string, args ...any) ([]ByokKey, error) {
|
||||||
|
rows, err := p.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
var out []ByokKey
|
||||||
|
for rows.Next() {
|
||||||
|
var k ByokKey
|
||||||
|
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.Type, &k.BaseURL, &k.APIKey, &k.Model, &k.CreatedAt, &k.LastUsed, &k.RevokedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) RevokeByok(userID, id string) error {
|
||||||
|
res, err := p.db.Exec(`UPDATE byok_keys SET revoked_at = $1 WHERE user_id = $2 AND id = $3 AND revoked_at IS NULL`,
|
||||||
|
time.Now().UTC(), userID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrByokNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) TouchByok(id string, at time.Time) error {
|
||||||
|
_, err := p.db.Exec(`UPDATE byok_keys SET last_used = $1 WHERE id = $2`, at, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) Reserve(userID string, amount int64) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// UPSERT + 原子累加;配额上限裁决在 quota 包(集中单一函数)
|
||||||
|
_, err := p.db.Exec(`INSERT INTO quota_balances (user_id, reserved, used) VALUES ($1, $2, 0)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET reserved = quota_balances.reserved + $2`, userID, amount)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) Settle(userID string, reserved, actual int64) error {
|
||||||
|
// 饱和截断在 SQL 层以 GREATEST 保证(防负)
|
||||||
|
_, err := p.db.Exec(`UPDATE quota_balances SET
|
||||||
|
reserved = GREATEST(reserved - $2, 0), used = used + $3 WHERE user_id = $1`,
|
||||||
|
userID, reserved, actual)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) Refund(userID string, reserved int64) error {
|
||||||
|
_, err := p.db.Exec(`UPDATE quota_balances SET reserved = GREATEST(reserved - $2, 0) WHERE user_id = $1`,
|
||||||
|
userID, reserved)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pgStore) Balance(userID string) (QuotaBalance, error) {
|
||||||
|
var b QuotaBalance
|
||||||
|
b.UserID = userID
|
||||||
|
err := p.db.QueryRow(`SELECT reserved, used FROM quota_balances WHERE user_id = $1`, userID).
|
||||||
|
Scan(&b.Reserved, &b.Used)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// Package store 神识自有库访问层:usage_logs / byok_keys / quota 余额。
|
||||||
|
//
|
||||||
|
// 双实现:PG(SHENSHI_DATABASE_URL,生产)与内存(开发/测试,未配置时回退)。
|
||||||
|
// 语义契约由 store_test.go 在内存实现上钉死;PG 实现为同接口直写 SQL。
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// PG 驱动(database/sql 标准接口)
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UsageLog 一次端点尝试的完整计量记录(shenshi-gateway.md §3 usage_logs)。
|
||||||
|
type UsageLog struct {
|
||||||
|
ID string // 尝试唯一标识(run_id#endpoint#seq,配额结算幂等键)
|
||||||
|
RunID string
|
||||||
|
CharacterID string
|
||||||
|
UserID string
|
||||||
|
Model string
|
||||||
|
Slot string
|
||||||
|
Endpoint string
|
||||||
|
Status string // ok / error / first_byte_timeout
|
||||||
|
ErrorCode string
|
||||||
|
FirstTokenMS int64
|
||||||
|
TotalMS int64
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageAgg 用量聚合行(/me/usage)。
|
||||||
|
type UsageAgg struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
OK int `json:"ok"`
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByokKey 用户自带密钥(API 永远脱敏返回)。
|
||||||
|
type ByokKey struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
Type string // openai_compatible / anthropic / gemini(契约词典 §14.2)
|
||||||
|
BaseURL string
|
||||||
|
APIKey string // 加密存储;出站时解密
|
||||||
|
Model string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUsed *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaBalance 用户配额账(三段式状态:Reserved 预扣 / Used 已结算)。
|
||||||
|
type QuotaBalance struct {
|
||||||
|
UserID string
|
||||||
|
Reserved int64
|
||||||
|
Used int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrByokNotFound BYOK 密钥不存在或已撤回。
|
||||||
|
var ErrByokNotFound = errors.New("BYOK 密钥不存在或已撤回")
|
||||||
|
|
||||||
|
// Store 存储接口(全异步语义由调用方保证;实现自身须并发安全)。
|
||||||
|
type Store interface {
|
||||||
|
InsertUsage(log UsageLog) error
|
||||||
|
AggregateUsage(userID string) ([]UsageAgg, error)
|
||||||
|
SampleUsage(limit int) ([]UsageLog, error) // 对账抽样:最近 N 条原始记录
|
||||||
|
|
||||||
|
UpsertByok(key ByokKey) error
|
||||||
|
ListByok(userID string) ([]ByokKey, error)
|
||||||
|
GetByok(userID, id string) (*ByokKey, error)
|
||||||
|
RevokeByok(userID, id string) error
|
||||||
|
TouchByok(id string, at time.Time) error
|
||||||
|
|
||||||
|
// Quota 三段式原子操作:Reserve 预扣(不足返回错误);Settle 结算差值;Refund 幂等退款。
|
||||||
|
Reserve(userID string, amount int64) error
|
||||||
|
Settle(userID string, reserved, actual int64) error
|
||||||
|
Refund(userID string, reserved int64) error
|
||||||
|
Balance(userID string) (QuotaBalance, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 按配置打开存储:DSN 非空时 PG(自动建表,含就绪重试),否则内存实现。
|
||||||
|
// 重试窗口 60 秒:容器编排下 PG 可能晚于神识就绪(跨 compose 文件无 depends_on)。
|
||||||
|
func Open(dsn string) (Store, error) {
|
||||||
|
if dsn == "" {
|
||||||
|
return NewMem(), nil
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开神识库失败: %w", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
for {
|
||||||
|
err = migrate(db)
|
||||||
|
if err == nil {
|
||||||
|
return &pgStore{db: db}, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("神识库迁移失败(60 秒重试后放弃): %w", err)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(db *sql.DB) error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS usage_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL DEFAULT '',
|
||||||
|
user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_code TEXT NOT NULL DEFAULT '',
|
||||||
|
first_token_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
completion_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
at TIMESTAMPTZ NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_usage_logs_user ON usage_logs(user_id, at DESC)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS byok_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_byok_user ON byok_keys(user_id)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS quota_balances (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
reserved BIGINT NOT NULL DEFAULT 0,
|
||||||
|
used BIGINT NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
return fmt.Errorf("建表失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内存实现 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
type memStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
usage []UsageLog
|
||||||
|
byok map[string][]ByokKey // user → keys
|
||||||
|
quota map[string]*QuotaBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMem 内存存储(保留最近 10 万条 usage,防无界增长)。
|
||||||
|
func NewMem() Store {
|
||||||
|
return &memStore{byok: map[string][]ByokKey{}, quota: map[string]*QuotaBalance{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
const memUsageLimit = 100_000
|
||||||
|
|
||||||
|
func (m *memStore) InsertUsage(log UsageLog) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if len(m.usage) >= memUsageLimit {
|
||||||
|
m.usage = m.usage[1:]
|
||||||
|
}
|
||||||
|
m.usage = append(m.usage, log)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) AggregateUsage(userID string) ([]UsageAgg, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
agg := map[string]*UsageAgg{}
|
||||||
|
for _, e := range m.usage {
|
||||||
|
if userID != "" && e.UserID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := e.UserID + "|" + e.Model + "|" + e.Endpoint
|
||||||
|
row := agg[key]
|
||||||
|
if row == nil {
|
||||||
|
row = &UsageAgg{UserID: e.UserID, Model: e.Model, Endpoint: e.Endpoint}
|
||||||
|
agg[key] = row
|
||||||
|
}
|
||||||
|
row.Requests++
|
||||||
|
if e.Status == "ok" {
|
||||||
|
row.OK++
|
||||||
|
row.PromptTokens += e.PromptTokens
|
||||||
|
row.CompletionTokens += e.CompletionTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]UsageAgg, 0, len(agg))
|
||||||
|
for _, row := range agg {
|
||||||
|
out = append(out, *row)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SampleUsage(limit int) ([]UsageLog, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
n := limit
|
||||||
|
if n > len(m.usage) {
|
||||||
|
n = len(m.usage)
|
||||||
|
}
|
||||||
|
out := make([]UsageLog, n)
|
||||||
|
copy(out, m.usage[len(m.usage)-n:])
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) UpsertByok(key ByokKey) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
keys := m.byok[key.UserID]
|
||||||
|
for i, k := range keys {
|
||||||
|
if k.ID == key.ID {
|
||||||
|
keys[i] = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.byok[key.UserID] = append(keys, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ListByok(userID string) ([]ByokKey, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]ByokKey, 0, len(m.byok[userID]))
|
||||||
|
for _, k := range m.byok[userID] {
|
||||||
|
if k.RevokedAt == nil {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetByok(userID, id string) (*ByokKey, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, k := range m.byok[userID] {
|
||||||
|
if k.ID == id && k.RevokedAt == nil {
|
||||||
|
out := k
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) RevokeByok(userID, id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
keys := m.byok[userID]
|
||||||
|
for i, k := range keys {
|
||||||
|
if k.ID == id && k.RevokedAt == nil {
|
||||||
|
keys[i].RevokedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrByokNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) TouchByok(id string, at time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for user, keys := range m.byok {
|
||||||
|
for i, k := range keys {
|
||||||
|
if k.ID == id {
|
||||||
|
used := at
|
||||||
|
keys[i].LastUsed = &used
|
||||||
|
m.byok[user] = keys
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) Reserve(userID string, amount int64) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
b := m.quotaGet(userID)
|
||||||
|
b.Reserved += amount
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) Settle(userID string, reserved, actual int64) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
b := m.quotaGet(userID)
|
||||||
|
b.Reserved -= reserved
|
||||||
|
if b.Reserved < 0 {
|
||||||
|
b.Reserved = 0
|
||||||
|
}
|
||||||
|
b.Used += actual
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) Refund(userID string, reserved int64) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
b := m.quotaGet(userID)
|
||||||
|
b.Reserved -= reserved
|
||||||
|
if b.Reserved < 0 {
|
||||||
|
b.Reserved = 0
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) Balance(userID string) (QuotaBalance, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if b, ok := m.quota[userID]; ok {
|
||||||
|
return *b, nil
|
||||||
|
}
|
||||||
|
return QuotaBalance{UserID: userID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) quotaGet(userID string) *QuotaBalance {
|
||||||
|
b, ok := m.quota[userID]
|
||||||
|
if !ok {
|
||||||
|
b = &QuotaBalance{UserID: userID}
|
||||||
|
m.quota[userID] = b
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// store 语义契约测试(内存实现;PG 实现同接口同语义)。
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newUsage(id, user, model, endpoint, status string, p, c int) UsageLog {
|
||||||
|
return UsageLog{ID: id, RunID: "run_t", UserID: user, Model: model, Slot: "chat",
|
||||||
|
Endpoint: endpoint, Status: status, PromptTokens: p, CompletionTokens: c,
|
||||||
|
At: time.Now().UTC()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageAggregateFiltersByUserAndCountsOK(t *testing.T) {
|
||||||
|
st := NewMem()
|
||||||
|
_ = st.InsertUsage(newUsage("1", "u1", "m", "e1", "ok", 10, 20))
|
||||||
|
_ = st.InsertUsage(newUsage("2", "u1", "m", "e1", "error", 0, 0))
|
||||||
|
_ = st.InsertUsage(newUsage("3", "u2", "m", "e2", "ok", 1, 2))
|
||||||
|
|
||||||
|
rows, err := st.AggregateUsage("u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("u1 聚合行数 = %d, 期望 1", len(rows))
|
||||||
|
}
|
||||||
|
r := rows[0]
|
||||||
|
if r.Requests != 2 || r.OK != 1 || r.PromptTokens != 10 || r.CompletionTokens != 20 {
|
||||||
|
t.Fatalf("聚合字段错误: %+v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, _ := st.AggregateUsage("")
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("全体聚合行数 = %d, 期望 2", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageSampleReturnsLatest(t *testing.T) {
|
||||||
|
st := NewMem()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_ = st.InsertUsage(newUsage(string(rune('a'+i)), "u1", "m", "e", "ok", i, 0))
|
||||||
|
}
|
||||||
|
rows, _ := st.SampleUsage(2)
|
||||||
|
if len(rows) != 2 || rows[1].ID != "e" {
|
||||||
|
t.Fatalf("抽样应返回最新 2 条(d,e),实际 %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByokLifecycle(t *testing.T) {
|
||||||
|
st := NewMem()
|
||||||
|
key := ByokKey{ID: "byok_x", UserID: "u1", Name: "主力", Type: "openai_compatible",
|
||||||
|
BaseURL: "https://api.example.com", APIKey: "enc", Model: "m1", CreatedAt: time.Now().UTC()}
|
||||||
|
if err := st.UpsertByok(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := st.GetByok("u1", "byok_x")
|
||||||
|
if err != nil || got == nil || got.Model != "m1" {
|
||||||
|
t.Fatalf("GetByok 失败: %v %+v", err, got)
|
||||||
|
}
|
||||||
|
// 跨用户隔离
|
||||||
|
if other, _ := st.GetByok("u2", "byok_x"); other != nil {
|
||||||
|
t.Fatal("u2 不应读到 u1 的密钥")
|
||||||
|
}
|
||||||
|
if err := st.RevokeByok("u1", "byok_x"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got2, _ := st.GetByok("u1", "byok_x")
|
||||||
|
if got2 != nil {
|
||||||
|
t.Fatal("撤回后不应可读")
|
||||||
|
}
|
||||||
|
if err := st.RevokeByok("u1", "byok_x"); err != ErrByokNotFound {
|
||||||
|
t.Fatalf("重复撤回应返回 ErrByokNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotaSaturatingRefund(t *testing.T) {
|
||||||
|
st := NewMem()
|
||||||
|
if err := st.Reserve("u1", 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 幂等退款:重复退款不产生负余额
|
||||||
|
if err := st.Refund("u1", 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Refund("u1", 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := st.Balance("u1")
|
||||||
|
if b.Reserved < 0 || b.Used != 0 {
|
||||||
|
t.Fatalf("重复退款后余额为负: %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotaSettleReleasesDifference(t *testing.T) {
|
||||||
|
st := NewMem()
|
||||||
|
_ = st.Reserve("u1", 100)
|
||||||
|
if err := st.Settle("u1", 100, 37); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := st.Balance("u1")
|
||||||
|
if b.Reserved != 0 || b.Used != 37 {
|
||||||
|
t.Fatalf("结算后应释放全部预扣并记实际用量: %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package transform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/ir"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readJSON(r *http.Request, v any) error {
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集全量增量(适配器测试通用)。
|
||||||
|
func collect(t *testing.T, ad Adaptor, req *ir.ChatRequest) []ir.Delta {
|
||||||
|
t.Helper()
|
||||||
|
out := make(chan ir.Delta, 64)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
done <- ad.Stream(context.Background(), req, out)
|
||||||
|
}()
|
||||||
|
var deltas []ir.Delta
|
||||||
|
for d := range out {
|
||||||
|
deltas = append(deltas, d)
|
||||||
|
}
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatalf("Stream 出错: %v", err)
|
||||||
|
}
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicAdaptorRequestAndStream(t *testing.T) {
|
||||||
|
var gotBody antRequest
|
||||||
|
var gotAuth, gotVersion bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
t.Errorf("路径错误: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
gotAuth = r.Header.Get("x-api-key") == "sk-test"
|
||||||
|
gotVersion = r.Header.Get("anthropic-version") != ""
|
||||||
|
_ = readJSON(r, &gotBody)
|
||||||
|
w.Header().Set("content-type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":11,\"output_tokens\":1}}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\",镜中\"}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":7}}\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ad := &AnthropicAdaptor{EndpointName: "ant", BaseURL: srv.URL, APIKey: "sk-test", UpstreamModel: "claude-x"}
|
||||||
|
req := &ir.ChatRequest{
|
||||||
|
Messages: []ir.Message{
|
||||||
|
{Role: "system", Content: "系统契约"},
|
||||||
|
{Role: "user", Content: "照心"},
|
||||||
|
},
|
||||||
|
RunID: "run_ant", Stream: true,
|
||||||
|
}
|
||||||
|
deltas := collect(t, ad, req)
|
||||||
|
|
||||||
|
if !gotAuth || !gotVersion {
|
||||||
|
t.Fatal("缺少 x-api-key / anthropic-version 头")
|
||||||
|
}
|
||||||
|
if gotBody.System == nil || *gotBody.System != "系统契约" {
|
||||||
|
t.Fatalf("system 应提升为顶层字段: %+v", gotBody.System)
|
||||||
|
}
|
||||||
|
if gotBody.MaxTokens == nil {
|
||||||
|
t.Fatal("Anthropic 要求 max_tokens 必填,适配器应兜底")
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
var last ir.Delta
|
||||||
|
for _, d := range deltas {
|
||||||
|
text += d.Delta
|
||||||
|
if d.FinishReason != "" {
|
||||||
|
last = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text != "你好,镜中" {
|
||||||
|
t.Fatalf("增量拼接错误: %q", text)
|
||||||
|
}
|
||||||
|
if last.FinishReason != "stop" || last.UsagePromptTokens != 11 || last.UsageCompletionTokens != 7 {
|
||||||
|
t.Fatalf("末帧用量错误: %+v", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicAdaptorSurfaces429(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"rate_limited"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
ad := &AnthropicAdaptor{BaseURL: srv.URL, APIKey: "k", UpstreamModel: "m"}
|
||||||
|
out := make(chan ir.Delta, 4)
|
||||||
|
err := ad.Stream(context.Background(), &ir.ChatRequest{RunID: "r", Stream: true, Messages: []ir.Message{{Role: "user", Content: "x"}}}, out)
|
||||||
|
up, ok := err.(*UpstreamError)
|
||||||
|
if !ok || up.StatusCode != 429 {
|
||||||
|
t.Fatalf("应携带 429 UpstreamError 供绑定级冷却,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiAdaptorRequestAndStream(t *testing.T) {
|
||||||
|
var gotPath, gotKey string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotKey = r.Header.Get("x-goog-api-key")
|
||||||
|
w.Header().Set("content-type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"第一\"}]}}]}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"第二\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":5,\"candidatesTokenCount\":9}}\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ad := &GeminiAdaptor{EndpointName: "gem", BaseURL: srv.URL, APIKey: "AIza-test", UpstreamModel: "gemini-x"}
|
||||||
|
req := &ir.ChatRequest{
|
||||||
|
Messages: []ir.Message{
|
||||||
|
{Role: "system", Content: "系统契约"},
|
||||||
|
{Role: "user", Content: "问"},
|
||||||
|
},
|
||||||
|
RunID: "run_gem", Stream: true,
|
||||||
|
}
|
||||||
|
deltas := collect(t, ad, req)
|
||||||
|
|
||||||
|
if gotPath != "/v1beta/models/gemini-x:streamGenerateContent" {
|
||||||
|
t.Fatalf("Gemini 路径错误: %s", gotPath)
|
||||||
|
}
|
||||||
|
if gotKey != "AIza-test" {
|
||||||
|
t.Fatal("缺少 x-goog-api-key 头")
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
var last ir.Delta
|
||||||
|
for _, d := range deltas {
|
||||||
|
text += d.Delta
|
||||||
|
if d.FinishReason != "" {
|
||||||
|
last = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text != "第一第二" {
|
||||||
|
t.Fatalf("增量拼接错误: %q", text)
|
||||||
|
}
|
||||||
|
if last.UsagePromptTokens != 5 || last.UsageCompletionTokens != 9 {
|
||||||
|
t.Fatalf("用量解析错误: %+v", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Anthropic 出站适配器:IR → Anthropic Messages API(SSE 事件流)。
|
||||||
|
package transform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/ir"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicAdaptor Anthropic Messages 端点适配器。
|
||||||
|
type AnthropicAdaptor struct {
|
||||||
|
EndpointName string
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
UpstreamModel string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 实现 Adaptor。
|
||||||
|
func (a *AnthropicAdaptor) Name() string { return a.EndpointName }
|
||||||
|
|
||||||
|
type antMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选标量一律指针 + omitempty(shenshi-gateway.md §5 DTO 纪律)。
|
||||||
|
type antRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []antMessage `json:"messages"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
System *string `json:"system,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// antEvent Anthropic SSE 事件:message_start 的用量在 message.usage,
|
||||||
|
// message_delta 的用量在顶层 usage(两处形态不同,指针区分可选标量)。
|
||||||
|
type antEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Delta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"delta"`
|
||||||
|
Message *struct {
|
||||||
|
Usage *struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
} `json:"message"`
|
||||||
|
Usage *struct {
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 实现 Adaptor:Anthropic SSE(message_delta / content_block_delta 等)转 IR Delta。
|
||||||
|
func (a *AnthropicAdaptor) Stream(ctx context.Context, req *ir.ChatRequest, out chan<- ir.Delta) error {
|
||||||
|
body := antRequest{
|
||||||
|
Model: a.UpstreamModel,
|
||||||
|
Messages: make([]antMessage, 0, len(req.Messages)),
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
Stream: true,
|
||||||
|
}
|
||||||
|
// Anthropic 的 system 是顶层字段而非消息序列首条
|
||||||
|
var system strings.Builder
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
if system.Len() > 0 {
|
||||||
|
system.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
system.WriteString(m.Content)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := m.Role
|
||||||
|
if role == "tool" {
|
||||||
|
role = "user" // 最小映射:tool 结果按用户侧内容回传(工具循环随百工 M5 完整化)
|
||||||
|
}
|
||||||
|
body.Messages = append(body.Messages, antMessage{Role: role, Content: m.Content})
|
||||||
|
}
|
||||||
|
if system.Len() > 0 {
|
||||||
|
s := system.String()
|
||||||
|
body.System = &s
|
||||||
|
}
|
||||||
|
if body.MaxTokens == nil {
|
||||||
|
def := 4096 // Anthropic 要求 max_tokens 必填
|
||||||
|
body.MaxTokens = &def
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
strings.TrimRight(a.BaseURL, "/")+"/v1/messages", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("content-type", "application/json")
|
||||||
|
httpReq.Header.Set("x-api-key", a.APIKey)
|
||||||
|
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
|
||||||
|
client := a.Client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return &UpstreamError{StatusCode: 0, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
return &UpstreamError{StatusCode: resp.StatusCode, Message: buf.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var finish string
|
||||||
|
var usagePrompt, usageCompletion int
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if data == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ev antEvent
|
||||||
|
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case "content_block_delta":
|
||||||
|
if ev.Delta.Type == "text_delta" && ev.Delta.Text != "" {
|
||||||
|
out <- ir.Delta{Delta: ev.Delta.Text}
|
||||||
|
}
|
||||||
|
case "message_delta":
|
||||||
|
// 结束与用量在 message_delta 顶层 usage 汇总(output_tokens)
|
||||||
|
if ev.Usage != nil {
|
||||||
|
usageCompletion = ev.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
finish = "stop"
|
||||||
|
case "message_start":
|
||||||
|
if ev.Message != nil && ev.Message.Usage != nil {
|
||||||
|
usagePrompt = ev.Message.Usage.InputTokens
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
return fmt.Errorf("上游流内错误: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if finish != "" {
|
||||||
|
out <- ir.Delta{FinishReason: finish, UsagePromptTokens: usagePrompt, UsageCompletionTokens: usageCompletion}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Gemini 出站适配器:IR → Google Generative Language streamGenerateContent(alt=sse)。
|
||||||
|
package transform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/ir"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeminiAdaptor Gemini generateContent 端点适配器。
|
||||||
|
type GeminiAdaptor struct {
|
||||||
|
EndpointName string
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
UpstreamModel string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 实现 Adaptor。
|
||||||
|
func (g *GeminiAdaptor) Name() string { return g.EndpointName }
|
||||||
|
|
||||||
|
type gemPart struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gemContent struct {
|
||||||
|
Role string `json:"role,omitempty"` // user / model(Gemini 无 system 角色)
|
||||||
|
Parts []gemPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gemGeneration struct {
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"maxOutputTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gemRequest struct {
|
||||||
|
Contents []gemContent `json:"contents"`
|
||||||
|
Generation *gemGeneration `json:"generationConfig,omitempty"`
|
||||||
|
SystemInstruction *gemContent `json:"systemInstruction,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gemChunk struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []gemPart `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
FinishReason *string `json:"finishReason"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
UsageMetadata *struct {
|
||||||
|
PromptTokenCount int `json:"promptTokenCount"`
|
||||||
|
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||||
|
} `json:"usageMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 实现 Adaptor:Gemini SSE 数据帧转 IR Delta。
|
||||||
|
func (g *GeminiAdaptor) Stream(ctx context.Context, req *ir.ChatRequest, out chan<- ir.Delta) error {
|
||||||
|
body := gemRequest{}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
switch m.Role {
|
||||||
|
case "system":
|
||||||
|
if body.SystemInstruction == nil {
|
||||||
|
body.SystemInstruction = &gemContent{}
|
||||||
|
}
|
||||||
|
body.SystemInstruction.Parts = append(body.SystemInstruction.Parts, gemPart{Text: m.Content})
|
||||||
|
case "assistant":
|
||||||
|
body.Contents = append(body.Contents, gemContent{Role: "model", Parts: []gemPart{{Text: m.Content}}})
|
||||||
|
default: // user / tool 结果按 user 侧回传(工具循环随百工 M5 完整化)
|
||||||
|
body.Contents = append(body.Contents, gemContent{Role: "user", Parts: []gemPart{{Text: m.Content}}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Temperature != nil || req.MaxTokens != nil {
|
||||||
|
body.Generation = &gemGeneration{Temperature: req.Temperature, MaxTokens: req.MaxTokens}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := strings.TrimRight(g.BaseURL, "/") +
|
||||||
|
"/v1beta/models/" + g.UpstreamModel + ":streamGenerateContent?alt=sse"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("content-type", "application/json")
|
||||||
|
httpReq.Header.Set("x-goog-api-key", g.APIKey)
|
||||||
|
|
||||||
|
client := g.Client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return &UpstreamError{StatusCode: 0, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
return &UpstreamError{StatusCode: resp.StatusCode, Message: buf.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if data == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var chunk gemChunk
|
||||||
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(chunk.Candidates) > 0 {
|
||||||
|
c := chunk.Candidates[0]
|
||||||
|
for _, part := range c.Content.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
out <- ir.Delta{Delta: part.Text}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.FinishReason != nil {
|
||||||
|
finish := "stop"
|
||||||
|
if strings.EqualFold(*c.FinishReason, "SAFETY") || strings.EqualFold(*c.FinishReason, "RECITATION") {
|
||||||
|
finish = "content_filter"
|
||||||
|
}
|
||||||
|
var up, uc int
|
||||||
|
if chunk.UsageMetadata != nil {
|
||||||
|
up, uc = chunk.UsageMetadata.PromptTokenCount, chunk.UsageMetadata.CandidatesTokenCount
|
||||||
|
}
|
||||||
|
out <- ir.Delta{FinishReason: finish, UsagePromptTokens: up, UsageCompletionTokens: uc}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
+39
-9
@@ -1,14 +1,19 @@
|
|||||||
// 神识(Shenshi)入口:魂镜模型网关,rewrite/1.0 M0 骨架。
|
// 神识(Shenshi)入口:魂镜模型网关(rewrite/1.0)。
|
||||||
// 数据面 POST /v1/chat/completions(IR 入站,SSE 出站);健康面 /healthz /readyz。
|
// 数据面 POST /v1/chat/completions(IR 入站,SSE 出站);健康面 /healthz /readyz;
|
||||||
|
// 计量面 /me/usage、对账抽样 /admin/usage/sample;BYOK 面 /me/byok-keys。
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"hunjing/shenshi/internal/byok"
|
||||||
|
"hunjing/shenshi/internal/quota"
|
||||||
"hunjing/shenshi/internal/route"
|
"hunjing/shenshi/internal/route"
|
||||||
"hunjing/shenshi/internal/server"
|
"hunjing/shenshi/internal/server"
|
||||||
|
"hunjing/shenshi/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func envOr(key, fallback string) string {
|
func envOr(key, fallback string) string {
|
||||||
@@ -19,13 +24,38 @@ func envOr(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := envOr("SHENSHI_PORT", "8080")
|
st, err := store.Open(os.Getenv("SHENSHI_DATABASE_URL"))
|
||||||
srv := server.New(
|
if err != nil {
|
||||||
server.Config{ServiceToken: os.Getenv("SHENSHI_SERVICE_TOKEN")},
|
log.Fatalf("神识存储初始化失败: %v", err)
|
||||||
route.New(),
|
}
|
||||||
)
|
|
||||||
addr := ":" + port
|
cfg := server.Config{
|
||||||
log.Printf("神识就绪:http://0.0.0.0%s(stub 端点,等待 M4 接入真实 Provider)", addr)
|
ServiceToken: os.Getenv("SHENSHI_SERVICE_TOKEN"),
|
||||||
|
Store: st,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BYOK:主密钥未配置时整体禁用(fail-closed)
|
||||||
|
if master := os.Getenv("SHENSHI_BYOK_MASTER_KEY"); master != "" {
|
||||||
|
vault, err := byok.NewVault(master, st)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("BYOK 主密钥配置无效: %v", err)
|
||||||
|
}
|
||||||
|
cfg.Vault = vault
|
||||||
|
log.Printf("BYOK 密钥库就绪")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配额:用户级软配额(token 口径;0 = 不限制)
|
||||||
|
var budget int64
|
||||||
|
if raw := os.Getenv("SHENSHI_USER_BUDGET_TOKENS"); raw != "" {
|
||||||
|
if _, err := fmt.Sscanf(raw, "%d", &budget); err != nil {
|
||||||
|
log.Fatalf("SHENSHI_USER_BUDGET_TOKENS 须为整数: %v", err)
|
||||||
|
}
|
||||||
|
cfg.Quota = quota.New(st, budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.New(cfg, route.New())
|
||||||
|
addr := ":" + envOr("SHENSHI_PORT", "8080")
|
||||||
|
log.Printf("神识就绪:http://0.0.0.0%s(端点目录 SHENSHI_ENDPOINTS,未配置时回声桩兜底)", addr)
|
||||||
if err := http.ListenAndServe(addr, srv); err != nil {
|
if err := http.ListenAndServe(addr, srv); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user