dbc29eee59
- 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>
307 lines
14 KiB
Python
307 lines
14 KiB
Python
# -*- 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=true(SSE 原样透传)
|
||
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"),
|
||
)
|