Files
server-core/app/api/routers/relay.py
T
Pine dbc29eee59 feat(membership): 成员制归属重构与叠加制身份(多对多成员表+转园两级审核)
- park_members/company_members 多对多成员表(0028),users.affiliation 为派生缓存由 sync_user_affiliation 刷新
- 转园两级审核状态机(0029):pending_from→pending_to→approved,支持用户/企业整体转园与运营端兜底
- 叠加制身份(0030):身份恒为运营/载体/OPC,企业管理员为能力叠加非身份,去除 enterprise/carrier 直写角色
- rbac 新增 memberships/park-transfers/企业管理员工作台 /admin/ent/* 接口

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:36:26 +08:00

307 lines
14 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
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)。
取该用户引擎名下第一枚有效令牌;没有则现场签发一枚,保证「登录用户=引擎用户」。
"""
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)
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
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):
"""把 chat/completions 转发到 compute-engine,流式 SSE 原样透传。"""
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))
headers = {**(await _auth_headers(request)), "Content-Type": "application/json"}
logger.info("[relay] chat model=%s stream=%s bytes=%d", body.get("model"), stream, len(json.dumps(body)))
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()
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"]
return StreamingResponse(
upstream.aiter_raw(),
status_code=upstream.status_code,
media_type="text/event-stream",
headers=resp_headers,
background=BackgroundTask(client.aclose),
)
@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"),
)