feat(compute): 归户闭环——relay 透传用户PAT + 用户用量/余额接口

- relay /v1 转发优先透传客户端 Authorization(用户算力PAT→compute归户), 无则回落 COMPUTE_RELAY_TOKEN(匿名)
- compute_client: user_balance/user_usage(取该用户一枚PAT鉴权查 /api/user/self|self/usage)
- rbac_opc 新增 /opc/compute/usage(本月按模型) + /opc/compute/balance
This commit is contained in:
Pine
2026-08-25 14:38:23 +08:00
parent 6685d80600
commit e5e9d265bf
3 changed files with 56 additions and 4 deletions
+18
View File
@@ -244,6 +244,24 @@ async def opc_compute_prices(_u: dict = Depends(require_roles("opc_member"))):
return {"items": await compute_catalog.prices()}
@router.get("/compute/usage", summary="我的用量(本月按模型)")
async def opc_compute_usage(user: dict = Depends(require_roles("opc_member"))):
try:
data = await compute_client.user_usage(user.get("username"))
except compute_client.ComputeError as exc:
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
return data
@router.get("/compute/balance", summary="我的算力余额")
async def opc_compute_balance(user: dict = Depends(require_roles("opc_member"))):
try:
data = await compute_client.user_balance(user.get("username"))
except compute_client.ComputeError as exc:
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
return data
@router.get("/compute/tokens", summary="我的算力令牌")
async def opc_compute_tokens(user: dict = Depends(require_roles("opc_member"))):
"""列出当前用户的引擎消费令牌。"""
+8 -4
View File
@@ -22,9 +22,13 @@ from ...services import compute_catalog
router = APIRouter(prefix="/v1", tags=["relay"])
def _auth_headers() -> dict[str, str]:
# /v1 转发用引擎能识别的真实消费令牌(PINEAGENTS_COMPUTE_RELAY_TOKEN),
# 与管理的 COMPUTE_ADMIN_TOKEN(内部合成 root) 区分;空令牌不下发,避免 `Bearer ` 非法被 httpx 拒绝
def _auth_headers(request: Request | None = None) -> dict[str, str]:
# 优先透传客户端 Authorization(用户算力 PAT → compute 归户计量)。
# 无则回落服务端消费令牌(COMPUTE_RELAY_TOKEN)→ compute 计匿名账
if request is not None:
client_auth = request.headers.get("Authorization", "")
if client_auth:
return {"Authorization": client_auth}
token = config.COMPUTE_RELAY_TOKEN or config.COMPUTE_ADMIN_TOKEN
if not token:
return {}
@@ -41,7 +45,7 @@ async def relay_chat_completions(request: Request):
chat_url = f"{config.COMPUTE_BASE_URL.rstrip('/')}/v1/chat/completions"
stream = bool(body.get("stream", False))
headers = {**_auth_headers(), "Content-Type": "application/json"}
headers = {**_auth_headers(request), "Content-Type": "application/json"}
client = httpx.AsyncClient(timeout=None)
upstream_request = client.build_request("POST", chat_url, json=body, headers=headers)
+30
View File
@@ -136,6 +136,36 @@ async def list_user_tokens(username: str) -> dict:
return (data.get("data") or {}).get("items") or []
async def _user_key(username: str) -> str:
"""取该引擎用户一枚有效 PAT(用作 /api/user/self* 鉴权)。"""
items = await list_user_tokens(username)
for it in items or []:
k = it.get("key") or it.get("token")
if k:
return k
return ""
async def user_balance(username: str) -> dict:
"""用户余额(PAT 鉴权)。"""
key = await _user_key(username)
if not key:
return {"quota": 0, "used_quota": 0, "balance": 0}
data = await _request(method="GET", path="/api/user/self",
headers={"Authorization": f"Bearer {key}"})
return data.get("data") or {}
async def user_usage(username: str) -> dict:
"""本月按模型用量/费用(PAT 鉴权)。"""
key = await _user_key(username)
if not key:
return {"items": [], "cost_quota": 0, "input_tokens": 0, "output_tokens": 0}
data = await _request(method="GET", path="/api/user/self/usage",
headers={"Authorization": f"Bearer {key}"})
return data.get("data") or {}
async def delete_token(token_id: int) -> dict:
"""删除引擎令牌。"""
return await _request(