Files
server-core/app/api/routers/relay.py
T
Pine edca0dcfa7 P1/P2 计费体系修复:统一扣费回调+请求前预检+对账接口+折扣同步
P1-1 统一扣费回调:
- 新增 POST /api/compute/internal/deduct(COMPUTE_ADMIN_TOKEN鉴权,engine_log_id幂等)
- compute 扣费后异步回调,按引擎实际费用(微元)扣平台来源账本
- 删除 relay.py 中 _bill_usage/_bill_usage_anthropic 调用(改由compute回调记账)
- ComputeUsageRecord 加 engine_log_id 字段,alembic 0079 迁移

P1-2 请求前预检:
- _user_engine_pat 中查 compute 余额(5s缓存),余额<=0直接403
- 避免无余额请求仍转发到引擎

P2-1 对账接口:
- GET /admin/compute/reconcile 对比引擎used_quota vs 平台累计扣费
- 支持分页、only_mismatch过滤

P2-2 折扣统一:
- 用户级折扣设置/删除时异步同步等效系数到引擎users.discount
- compute /api/user/manage 支持 username 定位用户
- compute_client 新增 set_user_discount_by_username
2026-09-13 17:41:02 +08:00

493 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""OpenAI 兼容模型中转端点。
PineAgents(8088) 的 ``pineagents`` provider 把 base_url 指向本服务的 ``/v1``
请求进来后丢弃客户端任何 ``Authorization``,统一替换为服务端凭据。
模型调用统一经 **compute-engine**loopback :3000)中转计量(引擎持有渠道 key,
前端不直连);走 ``PINEAGENTS_COMPUTE_ADMIN_TOKEN`` 管理令牌。流式 SSE 原样透传。
"""
from __future__ import annotations
from typing import Any
import json
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from starlette.background import BackgroundTask
from ... import config
from ...services import compute_catalog, compute_client
from ..dependencies import get_db
from ...infrastructure.repositories import Database
logger = logging.getLogger("relay")
router = APIRouter(prefix="/v1", tags=["relay"])
def _token_fp(token: str) -> str:
"""令牌指纹(前 6 位 + 长度):日志可定位但不可还原。"""
return f"{token[:6]}…({len(token)})" if token else "(empty)"
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
return fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else "")
def _is_jwt(token: str) -> bool:
"""平台 JWT 形如 header.payload.signature(两处 '.');引擎 PAT 不含 '.'"""
return token.count(".") == 2
def _strip_sk(token: str) -> str:
"""剥掉 OpenAI 惯例的 sk- 前缀:引擎 /v1 两种都认,但 /api/* 只认裸 key。"""
return token[3:] if token.startswith("sk-") else token
async def _user_engine_pat(token: str) -> str:
"""把平台 JWT 解析为该用户的引擎消费令牌(PAT)。
取该用户引擎名下第一枚有效令牌;没有则现场签发一枚,保证「登录用户=引擎用户」。
请求前预检:引擎余额 <=0 直接 403(5s 缓存,避免无余额仍转发到引擎)。
"""
from ...jwt import decode_access_token
claims = decode_access_token(token)
username = (claims or {}).get("username") or ""
if not username:
raise HTTPException(status_code=401, detail="登录态无效,请重新登录")
await compute_client.ensure_user(username)
# 请求前预检:余额 <=0 直接拒绝(5s 内存缓存,查询失败时放行由引擎侧兜底)
quota = await _get_cached_engine_balance(username)
if quota == 0:
raise HTTPException(status_code=403, detail="算力余额不足,请先充值")
items = await compute_client.list_user_tokens(username)
for it in items or []:
key = it.get("key") or it.get("token")
if key:
return key
issued = await compute_client.issue_user_token(username, name="平台中继令牌")
key = issued.get("key") or issued.get("token") or ""
if not key:
raise HTTPException(status_code=502, detail="算力令牌签发失败,请联系运营方")
logger.info("[relay] jwt→pat username=%s issued_new=%s", username, True)
return key
# 引擎余额预检缓存:username -> (timestamp, quota)5s TTL
_balance_cache: dict[str, tuple[float, int]] = {}
_BALANCE_CACHE_TTL = 5.0
async def _get_cached_engine_balance(username: str) -> int:
"""查 compute 引擎用户余额(5s 缓存)。查询失败返回 -1(放行,由引擎侧兜底)。"""
import time as _time
now = _time.time()
cached = _balance_cache.get(username)
if cached and now - cached[0] < _BALANCE_CACHE_TTL:
return cached[1]
try:
bal = await compute_client.user_balance(username)
quota = int(bal.get("quota", 0) or 0)
except Exception: # noqa: BLE001
quota = -1
_balance_cache[username] = (now, quota)
return quota
async def _auth_headers(request: Request) -> dict[str, str]:
"""模型调用鉴权:把请求归到「真实用户令牌」,由引擎按该用户余额计量。
- 客户端携带引擎 PAT:直接透传(compute 校验令牌与余额,无效即 401)。
- 客户端携带平台 JWT(桌面端/网页端登录态):解析用户 → 归户到其引擎 PAT。
- 二者皆无:**拒绝匿名**——不回落 COMPUTE_RELAY_TOKEN/COMPUTE_ADMIN_TOKEN
否则「用户无余额仍能免费使用」。
"""
client_auth = (request.headers.get("Authorization", "") or "").strip()
auth_src = "Authorization"
if not client_auth:
# 兼容部分客户端/网关:key 也可能放在 x-api-key / api-key / 查询参数里
for alt in ("x-api-key", "api-key", "x-goog-api-key"):
v = (request.headers.get(alt, "") or "").strip()
if v:
client_auth = v
auth_src = alt
break
else:
for q in ("key", "api_key", "token"):
v = (request.query_params.get(q, "") or "").strip()
if v:
client_auth = v
auth_src = f"query:{q}"
break
if not client_auth:
logger.warning("[relay] 401 no-token %s %s ip=%s hdrs(auth=%s,apikey=%s,xapikey=%s)",
request.method, request.url.path, _client_ip(request),
bool(request.headers.get("authorization")),
bool(request.headers.get("api-key")),
bool(request.headers.get("x-api-key")))
raise HTTPException(
status_code=401,
detail="缺少算力令牌:请携带 Bearer <用户算力令牌> 或登录态调用",
)
token = client_auth[7:].strip() if client_auth.lower().startswith("bearer ") else client_auth
if not token:
raise HTTPException(status_code=401, detail="算力令牌为空")
kind = "jwt" if _is_jwt(token) else "pat"
logger.info("[relay] auth %s %s ip=%s src=%s kind=%s token=%s",
request.method, request.url.path, _client_ip(request), auth_src, kind, _token_fp(token))
if _is_jwt(token):
try:
pat = await _user_engine_pat(token)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
return {"Authorization": f"Bearer {pat}"}
return {"Authorization": f"Bearer {_strip_sk(token)}"}
@router.post("/chat/completions")
async def relay_chat_completions(request: Request, db: Database = Depends(get_db)):
"""把 chat/completions 转发到 compute-engine,流式 SSE 原样透传。
平台 JWT 登录态调用:转发后按实际用量记账(先企业分配余额、后个人余额)。
引擎 PAT 调用(第三方 OpenAI 客户端):不记账,仅引擎计量。
"""
try:
body: dict[str, Any] = await request.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON body") from exc
chat_url = f"{config.COMPUTE_BASE_URL.rstrip('/')}/v1/chat/completions"
stream = bool(body.get("stream", False))
model = str(body.get("model") or "")
# 解析平台登录态 → user_id(仅 JWT 调用记账)
billing_user_id = await _resolve_billing_user(request, db)
headers = {**(await _auth_headers(request)), "Content-Type": "application/json"}
logger.info("[relay] chat model=%s stream=%s bytes=%d bill=%s", model, stream, len(json.dumps(body)), bool(billing_user_id))
client = httpx.AsyncClient(timeout=None)
upstream_request = client.build_request("POST", chat_url, json=body, headers=headers)
if not stream:
try:
upstream = await client.send(upstream_request)
payload = upstream.json() if upstream.content else None
except Exception: # noqa: BLE001
await client.aclose()
raise HTTPException(status_code=502, detail="Upstream relay failed") from None
await client.aclose()
# 记账由 compute 引擎扣费后异步回调 /api/compute/internal/deductengine_log_id 幂等)
return JSONResponse(status_code=upstream.status_code, content=payload)
try:
upstream = await client.send(upstream_request, stream=True)
except Exception: # noqa: BLE001
await client.aclose()
raise HTTPException(status_code=502, detail="Upstream relay failed") from None
resp_headers: dict[str, str] = {}
if "content-type" in upstream.headers:
resp_headers["content-type"] = upstream.headers["content-type"]
if "x-request-id" in upstream.headers:
resp_headers["x-request-id"] = upstream.headers["x-request-id"]
if not billing_user_id:
return StreamingResponse(
upstream.aiter_raw(),
status_code=upstream.status_code,
media_type="text/event-stream",
headers=resp_headers,
background=BackgroundTask(client.aclose),
)
# 流式:透传 SSE;记账由 compute 引擎扣费后异步回调(engine_log_id 幂等)
async def _stream_passthrough():
try:
async for raw in upstream.aiter_raw():
yield raw
finally:
try:
await client.aclose()
except Exception: # noqa: BLE001
pass
return StreamingResponse(
_stream_passthrough(),
status_code=upstream.status_code,
media_type="text/event-stream",
headers=resp_headers,
)
@router.post("/messages")
async def relay_messages(request: Request, db: Database = Depends(get_db)):
"""Anthropic 格式 POST /v1/messages:转发到 compute-engine。
鉴权与 chat 同规:Authorization: Bearer 或 x-api-key 均可(_auth_headers 已兼容);
平台 JWT 登录态调用:按 Anthropic usage(input/output_tokens) 记账;
引擎 PAT 调用(第三方 Anthropic 客户端):不额外记账,仅引擎计量。
流式:透传 SSE 并解析 message_delta.usage 记账。
"""
try:
body: dict[str, Any] = await request.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON body") from exc
messages_url = f"{config.COMPUTE_BASE_URL.rstrip('/')}/v1/messages"
stream = bool(body.get("stream", False))
model = str(body.get("model") or "")
billing_user_id = await _resolve_billing_user(request, db)
headers = {**(await _auth_headers(request)), "Content-Type": "application/json"}
logger.info("[relay] messages model=%s stream=%s bytes=%d bill=%s", model, stream, len(json.dumps(body)), bool(billing_user_id))
client = httpx.AsyncClient(timeout=None)
upstream_request = client.build_request("POST", messages_url, json=body, headers=headers)
if not stream:
try:
upstream = await client.send(upstream_request)
payload = upstream.json() if upstream.content else None
except Exception: # noqa: BLE001
await client.aclose()
raise HTTPException(status_code=502, detail="Upstream relay failed") from None
await client.aclose()
# 记账由 compute 引擎扣费后异步回调 /api/compute/internal/deduct
return JSONResponse(status_code=upstream.status_code, content=payload)
try:
upstream = await client.send(upstream_request, stream=True)
except Exception: # noqa: BLE001
await client.aclose()
raise HTTPException(status_code=502, detail="Upstream relay failed") from None
resp_headers: dict[str, str] = {}
if "content-type" in upstream.headers:
resp_headers["content-type"] = upstream.headers["content-type"]
if "x-request-id" in upstream.headers:
resp_headers["x-request-id"] = upstream.headers["x-request-id"]
if not billing_user_id:
return StreamingResponse(
upstream.aiter_raw(),
status_code=upstream.status_code,
media_type="text/event-stream",
headers=resp_headers,
background=BackgroundTask(client.aclose),
)
# 流式:透传 SSE;记账由 compute 引擎扣费后异步回调
async def _stream_passthrough_messages():
try:
async for raw in upstream.aiter_raw():
yield raw
finally:
try:
await client.aclose()
except Exception: # noqa: BLE001
pass
return StreamingResponse(
_stream_passthrough_messages(),
status_code=upstream.status_code,
media_type="text/event-stream",
headers=resp_headers,
)
async def _bill_usage_anthropic(db: Database, user_id: str, model: str, usage: dict) -> None:
"""Anthropic usageinput_tokens/output_tokens)→ 平台记账(best-effort)。"""
try:
from ...services.compute_pricing_service import deduct_usage_post
await deduct_usage_post(
db, user_id, model,
int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0),
int(usage.get("output_tokens") or usage.get("completion_tokens") or 0),
)
except Exception as exc: # noqa: BLE001
logger.warning("[relay] messages billing failed user=%s model=%s err=%s", user_id, model, exc)
async def _resolve_billing_user(request: Request, db: Database) -> str:
"""解析平台 JWT 登录态 → 平台 user_id;非 JWT(引擎 PAT)返回空串不记账。"""
client_auth = (request.headers.get("Authorization", "") or "").strip()
if not client_auth:
for alt in ("x-api-key", "api-key", "x-goog-api-key"):
v = (request.headers.get(alt, "") or "").strip()
if v:
client_auth = v
break
else:
for q in ("key", "api_key", "token"):
v = (request.query_params.get(q, "") or "").strip()
if v:
client_auth = v
break
token = client_auth[7:].strip() if client_auth.lower().startswith("bearer ") else client_auth
if not token or not _is_jwt(token):
return ""
try:
from ...jwt import decode_access_token
claims = decode_access_token(token)
username = (claims or {}).get("username") or ""
if not username:
return ""
user = await db.users.get_by_username(username)
return (user or {}).get("id", "") or ""
except Exception: # noqa: BLE001
return ""
async def _bill_usage(db: Database, user_id: str, model: str, usage: dict) -> None:
"""按实际用量记账:先企业分配余额、后个人余额(best-effort,失败仅记日志)。"""
try:
from ...services.compute_pricing_service import deduct_usage_post
await deduct_usage_post(
db, user_id, model,
int(usage.get("prompt_tokens") or 0),
int(usage.get("completion_tokens") or 0),
)
except Exception as exc: # noqa: BLE001
logger.warning("[relay] billing failed user=%s model=%s err=%s", user_id, model, exc)
@router.get("/models")
async def relay_models(_auth: dict = Depends(_auth_headers)):
"""可用模型清单(OpenAI GET /v1/models 口径,需令牌/登录态)。"""
ids = await compute_catalog.model_ids()
return {
"object": "list",
"data": [{"id": m, "object": "model", "owned_by": "pineagents"} for m in ids],
}
@router.get("/models/{model_id}")
async def relay_model_detail(model_id: str, _auth: dict = Depends(_auth_headers)):
"""单个模型详情(OpenAI GET /v1/models/{model} 口径)。"""
return {"id": model_id, "object": "model", "owned_by": "pineagents"}
# ---------------------------------------------------------------------------
# 余额(OpenAI billing 口径:多数客户端如 Cherry Studio/NextChat 用这两个端点展示余额)
# 口径:引擎 quota 单位 = 1e6 微元/元;subscription.hard_limit_usd = 总额度(元数),
# usage.total_usage = 已用(「美分」= 元 × 100),客户端显示 余额 = hard_limit - total_usage/100。
# ---------------------------------------------------------------------------
def _quota_per_unit() -> float:
return 1_000_000.0
async def _engine_self(request: Request) -> dict:
"""按调用方令牌归户,查引擎 /api/user/self(余额/已用)。"""
pat = (await _auth_headers(request))["Authorization"].split(" ", 1)[1]
if _is_jwt(pat):
raise HTTPException(status_code=401, detail="余额查询请携带用户算力令牌")
pat = _strip_sk(pat)
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(
f"{config.COMPUTE_BASE_URL.rstrip('/')}/api/user/self",
headers={"Authorization": f"Bearer {pat}"},
)
except Exception as exc: # noqa: BLE001
logger.warning("[relay] billing self 查询失败 token=%s err=%s", _token_fp(pat), exc)
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
data = (r.json() or {}).get("data") or {}
# 引擎对无效令牌也返回 200 + data.error(如「无效令牌」),必须显式暴露而非静默 0
if data.get("error"):
logger.warning("[relay] billing self 无效令牌 token=%s engine=%s", _token_fp(pat), data.get("error"))
raise HTTPException(status_code=401, detail=f"算力令牌无效:{data.get('error')}")
if r.status_code != 200:
logger.warning("[relay] billing self 非200 status=%s token=%s", r.status_code, _token_fp(pat))
raise HTTPException(status_code=502, detail="算力引擎余额查询失败")
logger.info("[relay] billing quota=%s used=%s", data.get("quota"), data.get("used_quota"))
return data
@router.get("/dashboard/billing/subscription")
async def relay_billing_subscription(request: Request):
self_data = await _engine_self(request)
quota = float(self_data.get("quota") or 0)
total = quota / _quota_per_unit()
return {
"object": "billing_subscription",
"has_payment_method": True,
"soft_limit_usd": total,
"hard_limit_usd": total,
"system_hard_limit_usd": total,
"access_until": 0,
}
@router.get("/dashboard/billing/usage")
async def relay_billing_usage(request: Request, date: str = ""):
self_data = await _engine_self(request)
used = float(self_data.get("used_quota") or 0)
return {
"object": "list",
"total_usage": used / _quota_per_unit() * 100, # 美分口径(元×100
"daily_costs": [],
}
# ---------------------------------------------------------------------------
# 通用透传(OpenAI 规范其余端点:completions/embeddings/images/audio/moderations/
# responses/realtime… 以及 /v1 下任何路径),流式 SSE 原样透传,鉴权与 chat 同规。
# ---------------------------------------------------------------------------
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def relay_catch_all(request: Request, path: str):
engine_url = f"{config.COMPUTE_BASE_URL.rstrip('/')}/v1/{path}"
if str(request.url.query):
engine_url = f"{engine_url}?{request.url.query}"
headers = {**(await _auth_headers(request)), "Content-Type": request.headers.get("content-type", "application/json")}
body = await request.body()
# 流式判定:JSON body 里 stream=trueSSE 原样透传)
wants_stream = False
if body:
try:
wants_stream = bool(json.loads(body).get("stream"))
except Exception: # noqa: BLE001
wants_stream = False
client = httpx.AsyncClient(timeout=None)
upstream_request = client.build_request(request.method, engine_url, content=body, headers=headers)
logger.info("[relay] pass %s /%s%s stream=%s bytes=%d", request.method, path, engine_url.split('?')[0], wants_stream, len(body))
if wants_stream:
try:
upstream = await client.send(upstream_request, stream=True)
except Exception: # noqa: BLE001
await client.aclose()
logger.error("[relay] pass %s /%s 上游连接失败", request.method, path)
raise HTTPException(status_code=502, detail="Upstream relay failed") from None
logger.info("[relay] pass %s /%s → upstream %s (stream)", request.method, path, upstream.status_code)
resp_headers = {k: upstream.headers[k] for k in ("content-type", "x-request-id") if k in upstream.headers}
return StreamingResponse(
upstream.aiter_raw(), status_code=upstream.status_code,
media_type=upstream.headers.get("content-type", "application/json"),
headers=resp_headers, background=BackgroundTask(client.aclose),
)
try:
upstream = await client.send(upstream_request)
payload = upstream.content
except Exception: # noqa: BLE001
await client.aclose()
logger.error("[relay] pass %s /%s 上游连接失败", request.method, path)
raise HTTPException(status_code=502, detail="Upstream relay failed") from None
await client.aclose()
if upstream.status_code >= 400:
logger.warning("[relay] pass %s /%s → upstream %s body=%s",
request.method, path, upstream.status_code, payload[:300])
return Response(
content=payload, status_code=upstream.status_code,
media_type=upstream.headers.get("content-type", "application/json"),
)