feat(proxy): T-P4 路由端到端(M1:计费代理跑通)
- routes.py 主时序:auth(热缓存) -> 限流/并发槽 -> 413 -> 池条目解析 -> try_hold 预扣(字符/3 + min(max_tokens,4096) 宁可高估)-> 上游流式 tee -> usage 归一 -> compute -> settle(actual 回补) -> 流水;异常路径: UpstreamError 全额 void / UpstreamAborted 按已收 usage 结算 status=aborted - install_error_handlers:ProxyError 家族 -> §5.3 错误码 (401/402/403/413/429/502,401 带 WWW-Authenticate) - 管理面:students/topup/keys 签发/keys-revoke(X-Admin-Key 或 loopback) - SSE 同构:chunk 补齐 object/created/id/model(OpenAI SDK 兼容形状), usage chunk 按客户端要求过滤 - 修复:补回 T-P4 重写时丢失的 /proxy/v1/models - 测试 +8(非流式全程含 2550/1491 黄金账目/流式/402/401/413/502 void/ httpx 手写 OpenAI SDK 合规断言含 chunk 形状+usage 过滤+[DONE]),全量 362 passed
This commit is contained in:
@@ -209,6 +209,8 @@ try:
|
||||
_proxy_cfg = build_proxy_config(settings_store().to_dict())
|
||||
if _proxy_cfg.enabled:
|
||||
app.include_router(build_proxy_router(_proxy_cfg, get_pool()))
|
||||
from gateway.proxy.routes import install_error_handlers
|
||||
install_error_handlers(app)
|
||||
except Exception as _pe: # pragma: no cover - 代理层装配失败不拖垮主应用
|
||||
print(f"[gateway] 代理层未启用({_pe})")
|
||||
|
||||
|
||||
+305
-12
@@ -1,28 +1,321 @@
|
||||
"""代理面路由(T-P0 骨架:/proxy/v1/models;主对话路由 T-P4 落地)。"""
|
||||
"""代理面路由(T-P4):学生面对话 + 管理面 CRUD。
|
||||
|
||||
主时序(§7,缓存分支 T-P6 接入):
|
||||
auth -> 限流/日额 -> body 校验(413) -> 预扣 try_hold(est) -> 上游流式 tee
|
||||
-> usage 归一 -> compute -> settle(actual, 回补) -> record(流水)
|
||||
异常:首 token 前 failover 均失败 -> void;流中失败 -> aborted 按已收 usage 结算。
|
||||
|
||||
管理面:X-Admin-Key(hmac.compare_digest;未配置仅 loopback)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from gateway.proxy.auth import authenticate, verify_admin
|
||||
from gateway.proxy.config import ProxyConfig
|
||||
from gateway.proxy.errors import (
|
||||
AdminAuthError,
|
||||
BalanceError,
|
||||
BodyTooLargeError,
|
||||
ProxyAuthError,
|
||||
QuotaError,
|
||||
SuspendedError,
|
||||
UpstreamError,
|
||||
)
|
||||
from gateway.proxy.pricing import compute
|
||||
from gateway.proxy.upstream import UpstreamAborted, filter_usage_chunk, stream as upstream_stream
|
||||
|
||||
|
||||
def install_error_handlers(app) -> None:
|
||||
"""把 ProxyError 家族映射为 §5.3 错误码(401/402/403/413/429/502)。"""
|
||||
from gateway.proxy.errors import ProxyError
|
||||
|
||||
@app.exception_handler(ProxyError)
|
||||
async def _proxy_error_handler(request: Request, exc: ProxyError):
|
||||
return JSONResponse(
|
||||
{"error": {"message": str(exc), "type": exc.code}},
|
||||
status_code=exc.status_code,
|
||||
headers={"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None)
|
||||
|
||||
|
||||
def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
|
||||
"""组装代理面路由(唯一组装点;pool 为 gateway.model_pool.PoolStore)。"""
|
||||
"""组装代理面路由(唯一组装点)。"""
|
||||
router = APIRouter(prefix="/proxy")
|
||||
|
||||
ledger = __import__("gateway.proxy.ledger", fromlist=["Ledger"]).Ledger.init_db(cfg.db_path)
|
||||
# ---------------- 学生面 ----------------
|
||||
@router.get("/v1/models", tags=["proxy"])
|
||||
async def list_models():
|
||||
"""学生面:池内允许代理的模型列表(OpenAI /models 形状)。"""
|
||||
entries = pool.list().get("entries", [])
|
||||
names = [e["model"] for e in entries
|
||||
if e.get("enabled") and e.get("backend") not in ("mock",)]
|
||||
# 去重保序
|
||||
seen, data = set(), []
|
||||
for n in names:
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
data.append({"id": n, "object": "model", "owned_by": "campus-proxy"})
|
||||
for e in entries:
|
||||
if not e.get("enabled") or e.get("backend") in ("mock",):
|
||||
continue
|
||||
mid = e.get("model") or ""
|
||||
if mid and mid not in seen:
|
||||
seen.add(mid)
|
||||
data.append({"id": mid, "object": "model", "owned_by": "campus-proxy"})
|
||||
return {"object": "list", "data": data}
|
||||
|
||||
# /v1/chat/completions 与 /admin/* 在 T-P1/T-P4 追加
|
||||
@router.post("/v1/chat/completions", tags=["proxy"])
|
||||
async def chat_completions(request: Request):
|
||||
raw_body = await request.body()
|
||||
if len(raw_body) > cfg.max_body_chars:
|
||||
raise BodyTooLargeError(f"请求体超过 {cfg.max_body_chars} 字符")
|
||||
try:
|
||||
body = json.loads(raw_body or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": {"message": "请求体不是合法 JSON"}}, status_code=400)
|
||||
|
||||
ctx = authenticate(request.headers.get("authorization", ""), ledger,
|
||||
_limits(), time.time())
|
||||
if not _limits().acquire_slot(ctx["key_id"]):
|
||||
raise QuotaError("并发请求已达该 key 上限")
|
||||
try:
|
||||
return await _run_chat(body, dict(request.headers), ctx, cfg, ledger, pool)
|
||||
finally:
|
||||
_limits().release_slot(ctx["key_id"])
|
||||
|
||||
# ---------------- 管理面 ----------------
|
||||
def _guard_admin(request: Request) -> None:
|
||||
host = request.client.host if request.client else ""
|
||||
if not verify_admin(request.headers.get("x-admin-key", ""), cfg.admin_key, host):
|
||||
raise AdminAuthError("管理面鉴权失败")
|
||||
|
||||
@router.post("/admin/students", tags=["proxy-admin"])
|
||||
async def admin_create_student(request: Request, payload: dict):
|
||||
_guard_admin(request)
|
||||
name = str((payload or {}).get("name") or "").strip()
|
||||
if not name:
|
||||
return JSONResponse({"error": "name 必填"}, status_code=400)
|
||||
sid = ledger.upsert_student(
|
||||
name, klass=str((payload or {}).get("class") or ""),
|
||||
balance_yuan=float((payload or {}).get("balance_yuan", 0) or 0),
|
||||
daily_cap_yuan=float((payload or {}).get("daily_cap_yuan", 5) or 5))
|
||||
return {"student_id": sid, **(ledger.get_student(sid) or {})}
|
||||
|
||||
@router.post("/admin/students/{student_id}/topup", tags=["proxy-admin"])
|
||||
async def admin_topup(student_id: int, request: Request, payload: dict):
|
||||
_guard_admin(request)
|
||||
amount = float((payload or {}).get("amount_yuan", 0) or 0)
|
||||
new_balance = ledger.topup(student_id, amount)
|
||||
if new_balance is None:
|
||||
return JSONResponse({"error": f"学生不存在: {student_id}"}, status_code=404)
|
||||
return {"ok": True, "balance_milli": new_balance}
|
||||
|
||||
@router.post("/admin/keys", tags=["proxy-admin"])
|
||||
async def admin_issue_key(request: Request, payload: dict):
|
||||
_guard_admin(request)
|
||||
from gateway.proxy.auth import issue_key
|
||||
student_id = int((payload or {}).get("student_id", 0) or 0)
|
||||
try:
|
||||
return issue_key(ledger, student_id,
|
||||
rpm_cap=payload.get("rpm_cap"),
|
||||
day_cap_req=payload.get("day_cap_req"))
|
||||
except ProxyAuthError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=404)
|
||||
|
||||
@router.post("/admin/keys/{key_id}/revoke", tags=["proxy-admin"])
|
||||
async def admin_revoke_key(key_id: int, request: Request):
|
||||
_guard_admin(request)
|
||||
from gateway.proxy.auth import _AUTH_SINGLETON
|
||||
ok = ledger.revoke_key(key_id)
|
||||
return {"ok": ok}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _limits():
|
||||
from gateway.proxy.auth import _AUTH_SINGLETON_LIMITS
|
||||
return _AUTH_SINGLETON_LIMITS
|
||||
|
||||
|
||||
def _estimate_hold_milli(body: dict, cfg: ProxyConfig, model: str) -> int:
|
||||
"""预扣估算(宁可高估,D-P11):in 按字符/3,out 按 min(max_tokens,4096)。
|
||||
|
||||
只能用已配置模型的峰值单价;未配置模型按 default 桶兜底价 or 最高价。
|
||||
"""
|
||||
price = cfg.price(model)
|
||||
if price is None:
|
||||
prices = cfg.model_prices.values()
|
||||
if not prices:
|
||||
return 1
|
||||
in_p = max(p.in_miss for p in prices)
|
||||
hit_p = max(p.in_hit for p in prices)
|
||||
out_p = max(p.out for p in prices)
|
||||
else:
|
||||
in_p, hit_p, out_p = price.in_miss, price.in_hit, price.out
|
||||
prompt_chars = sum(len(str(m.get("content") or ""))
|
||||
for m in (body.get("messages") or []))
|
||||
est_in = prompt_chars // 3
|
||||
est_out = int(body.get("max_tokens") or 1024)
|
||||
est_out = min(est_out, 4096)
|
||||
cost = (est_in * in_p + est_out * out_p) / 1_000_000
|
||||
return max(1, int(round(cost))) # 保守:全部按未命中价
|
||||
|
||||
|
||||
def _resolve_entry(pool, model: str, cfg: ProxyConfig) -> Optional[Dict[str, Any]]:
|
||||
"""按模型名取池条目(仅启用的真实后端)。"""
|
||||
e = pool.find_by_model(model)
|
||||
if e and e.get("enabled") and e.get("backend") not in ("mock",) and e.get("base_url"):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
cfg: ProxyConfig, ledger, pool):
|
||||
model = str(body.get("model") or "")
|
||||
entry = _resolve_entry(pool, model, cfg)
|
||||
if entry is None:
|
||||
return JSONResponse({"error": {"message": f"模型不可用: {model}",
|
||||
"type": "invalid_request_error"}},
|
||||
status_code=400)
|
||||
request_id = "px" + uuid.uuid4().hex[:12]
|
||||
ts = time.time()
|
||||
client_wants_usage = bool(body.get("stream_options", {}).get("include_usage")) \
|
||||
if isinstance(body.get("stream_options"), dict) else False
|
||||
is_stream = bool(body.get("stream"))
|
||||
|
||||
est = _estimate_hold_milli(body, cfg, model)
|
||||
if not await asyncio.to_thread(
|
||||
ledger.try_hold, request_id, ctx["key_id"], ctx["student_id"],
|
||||
model, "default", est, ts):
|
||||
raise BalanceError("余额或当日额度不足")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sink: Dict[str, Any] = {}
|
||||
try:
|
||||
if is_stream:
|
||||
return await _stream_response(body, entry, sink, headers, client_wants_usage,
|
||||
request_id, ctx, cfg, ledger, model, est, t0)
|
||||
return await _json_response(body, entry, sink, request_id, ctx, cfg,
|
||||
ledger, model, est, t0)
|
||||
except UpstreamAborted as e:
|
||||
# 流中失败:按已收 usage 结算(无 usage 按字符估算),不缓存(D-P4)
|
||||
usage = sink.get("usage") or _estimate_usage_from_sink(sink)
|
||||
br = compute(usage, model, ts, cfg)
|
||||
await asyncio.to_thread(
|
||||
ledger.settle, request_id, br["charged_milli"],
|
||||
in_miss_tok=usage.get("in_miss", 0), in_hit_tok=usage.get("in_hit", 0),
|
||||
out_tok=usage.get("out", 0), upstream_cost_milli=br["upstream_cost_milli"],
|
||||
total_ms=int((time.perf_counter() - t0) * 1000), status="aborted")
|
||||
return JSONResponse({"error": {"message": f"上游流中断: {e}",
|
||||
"type": "aborted"}}, status_code=502)
|
||||
except UpstreamError as e:
|
||||
# 首 token 前失败(failover 均失败):全额退款
|
||||
await asyncio.to_thread(ledger.void, request_id, status="error")
|
||||
return JSONResponse({"error": {"message": str(e), "type": "upstream_error"}},
|
||||
status_code=502)
|
||||
except Exception as e: # noqa: BLE001
|
||||
await asyncio.to_thread(ledger.void, request_id, status="error")
|
||||
raise
|
||||
|
||||
|
||||
def _estimate_usage_from_sink(sink: Dict[str, Any]) -> Dict[str, int]:
|
||||
text = sink.get("text", "")
|
||||
return {"in_miss": 0, "in_hit": 0, "out": len(text) // 4}
|
||||
|
||||
|
||||
async def _stream_response(body, entry, sink, headers, client_wants_usage,
|
||||
request_id, ctx, cfg, ledger, model, est, t0):
|
||||
usage = {"in_miss": 0, "in_hit": 0, "out": 0}
|
||||
|
||||
async def gen():
|
||||
collected = []
|
||||
chunk_id = f"chatcmpl-{request_id}"
|
||||
created = int(time.time())
|
||||
try:
|
||||
async for raw_bytes in upstream_stream(body, entry, sink, [entry]):
|
||||
line = raw_bytes.decode("utf-8").strip()
|
||||
if not line:
|
||||
continue
|
||||
for sub in line.split("\n\n"):
|
||||
if not sub:
|
||||
continue
|
||||
filtered = filter_usage_chunk(sub, client_wants_usage)
|
||||
if filtered is None:
|
||||
continue
|
||||
# 同构补齐(§5.1):确保 OpenAI chunk 形状(object/created/id/model)
|
||||
out_line = filtered
|
||||
if filtered.startswith("data:") and "[DONE]" not in filtered:
|
||||
try:
|
||||
obj = json.loads(filtered[5:].strip())
|
||||
obj.setdefault("object", "chat.completion.chunk")
|
||||
obj.setdefault("id", chunk_id)
|
||||
obj.setdefault("created", created)
|
||||
obj.setdefault("model", model)
|
||||
delta = (obj.get("choices") or [{}])[0].get("delta") or {}
|
||||
collected.append(str(delta.get("content") or ""))
|
||||
out_line = "data: " + json.dumps(obj, ensure_ascii=False)
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
pass
|
||||
yield (out_line + "\n\n").encode("utf-8")
|
||||
sink["text"] = "".join(collected)
|
||||
finally:
|
||||
u = sink.get("usage") or {"in_miss": 0, "in_hit": 0, "out": 0}
|
||||
br = compute(u, model, time.time(), cfg)
|
||||
await asyncio.to_thread(
|
||||
ledger.settle, request_id, br["charged_milli"],
|
||||
in_miss_tok=u.get("in_miss", 0), in_hit_tok=u.get("in_hit", 0),
|
||||
out_tok=u.get("out", 0), upstream_cost_milli=br["upstream_cost_milli"],
|
||||
ttfb_ms=sink.get("ttfb_ms"),
|
||||
total_ms=int((time.perf_counter() - t0) * 1000),
|
||||
status="ok")
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-Request-Id": request_id})
|
||||
|
||||
|
||||
async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
|
||||
model, est, t0):
|
||||
parts = []
|
||||
async for raw_bytes in upstream_stream(body, entry, sink, [entry]):
|
||||
line = raw_bytes.decode("utf-8").strip()
|
||||
if not line:
|
||||
continue
|
||||
for sub in line.split("\n\n"):
|
||||
if sub.startswith("data:"):
|
||||
payload = sub[5:].strip()
|
||||
if payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
delta = (obj.get("choices") or [{}])[0].get("delta") or {}
|
||||
if delta.get("content"):
|
||||
parts.append(str(delta["content"]))
|
||||
if obj.get("finish_reason") or (obj.get("choices") or [{}])[0].get("finish_reason"):
|
||||
sink.setdefault("finish_reason",
|
||||
(obj.get("choices") or [{}])[0].get("finish_reason"))
|
||||
usage = sink.get("usage") or {"in_miss": 0, "in_hit": 0,
|
||||
"out": len("".join(parts)) // 4}
|
||||
br = compute(usage, model, time.time(), cfg)
|
||||
total_ms = int((time.perf_counter() - t0) * 1000)
|
||||
await asyncio.to_thread(
|
||||
ledger.settle, request_id, br["charged_milli"],
|
||||
in_miss_tok=usage.get("in_miss", 0), in_hit_tok=usage.get("in_hit", 0),
|
||||
out_tok=usage.get("out", 0), upstream_cost_milli=br["upstream_cost_milli"],
|
||||
ttfb_ms=sink.get("ttfb_ms"), total_ms=total_ms, status="ok")
|
||||
return JSONResponse({
|
||||
"id": f"chatcmpl-{request_id}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0,
|
||||
"message": {"role": "assistant",
|
||||
"content": "".join(parts)},
|
||||
"finish_reason": sink.get("finish_reason") or "stop"}],
|
||||
"usage": {"prompt_tokens": usage.get("in_miss", 0) + usage.get("in_hit", 0),
|
||||
"completion_tokens": usage.get("out", 0),
|
||||
"total_tokens": usage.get("in_miss", 0) + usage.get("in_hit", 0)
|
||||
+ usage.get("out", 0)},
|
||||
}, headers={"X-Request-Id": request_id})
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""代理路由端到端测试(T-P4,M1 验收):全程计费/错误码/SSE 流式/httpx 合规客户端。"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import gateway.proxy.upstream as up
|
||||
import gateway.model_pool as mp
|
||||
from gateway.model_pool import PoolStore
|
||||
from gateway.proxy.auth import issue_key, reset_auth_state
|
||||
from gateway.proxy.config import build_proxy_config
|
||||
from gateway.proxy.errors import (
|
||||
BalanceError,
|
||||
BodyTooLargeError,
|
||||
ProxyAuthError,
|
||||
QuotaError,
|
||||
)
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
UPSTREAM_SSE = "\n\n".join([
|
||||
'data: {"choices":[{"delta":{"role":"assistant","content":"\u4f60\u597d"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"\uff0c\u4e16\u754c"}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],'
|
||||
# 智能体任务级用量:in_miss=600k, in_hit=300k, out=80k
|
||||
'"usage":{"prompt_tokens":900000,"prompt_cache_hit_tokens":300000,"completion_tokens":80000}}',
|
||||
"data: [DONE]",
|
||||
]) + "\n\n"
|
||||
|
||||
|
||||
def _make_app(tmp_path, upstream_body=UPSTREAM_SSE, fail_upstream=False):
|
||||
"""独立 FastAPI app + 池 + key。返回 (client, ledger, key, upstream_calls)。"""
|
||||
mp.reset_pool()
|
||||
mp._store = PoolStore(path=tmp_path / "pool.json")
|
||||
mp.get_pool().upsert({
|
||||
"id": "up1", "name": "云端", "tier": "budget", "backend": "openai",
|
||||
"base_url": "http://upstream.test", "model": "deepseek-chat",
|
||||
"api_key": "up-key", "provider": "deepseek",
|
||||
"price_in": 0.1, "price_out": 0.1, "enabled": True})
|
||||
reset_auth_state()
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
if fail_upstream:
|
||||
raise httpx.ConnectError("上游不可达")
|
||||
return httpx.Response(200, content=upstream_body.encode("utf-8"))
|
||||
|
||||
import gateway.proxy.upstream as upmod
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
orig = upmod._client
|
||||
upmod._client = client
|
||||
|
||||
cfg = build_proxy_config({"proxy": {
|
||||
"enabled": True,
|
||||
"db_path": str(tmp_path / "proxy.sqlite3"),
|
||||
"buckets": {"default": {"system_template": "你是校园学习助手。",
|
||||
"doc_prefix_file": None, "doc_version": 1,
|
||||
"ttl_hours": 72}},
|
||||
"pricing": {"deepseek-chat": {"in_miss": 3.0, "in_hit": 0.1, "out": 9.0},
|
||||
"peak_window": {"start": "00:00", "end": "23:59"},
|
||||
"offpeak_factor": 0.5,
|
||||
"sale_discount": {"in": 0.5, "out": 0.8}},
|
||||
"limits": {"rpm_per_key": 100, "day_req_cap": 1000,
|
||||
"concurrent_per_key": 8, "max_body_chars": 6000},
|
||||
}})
|
||||
from gateway.proxy.routes import build_proxy_router, install_error_handlers
|
||||
app = FastAPI()
|
||||
app.include_router(build_proxy_router(cfg, mp.get_pool()))
|
||||
install_error_handlers(app)
|
||||
ledger = app.router.routes # noqa: F841(占位说明:ledger 经 state 可取)
|
||||
st_ledger = None
|
||||
for r in app.routes:
|
||||
st = getattr(r, "state", None)
|
||||
# 直接从 routes builder 拿 ledger:router.state 在 APIRouter 上不可用,
|
||||
# 用模块内函数重建一次(同 db path 幂等)
|
||||
from gateway.proxy.ledger import Ledger
|
||||
st_ledger = Ledger.init_db(tmp_path / "proxy.sqlite3")
|
||||
|
||||
tc = TestClient(app)
|
||||
key = issue_key(st_ledger, _mk_student(st_ledger), rpm_cap=100, day_cap_req=1000)
|
||||
return tc, st_ledger, key, calls, (upmod, orig, client)
|
||||
|
||||
|
||||
def _mk_student(ledger):
|
||||
return ledger.upsert_student("学生A", balance_yuan=10.0, daily_cap_yuan=100)
|
||||
|
||||
|
||||
def _auth(key):
|
||||
return {"Authorization": f"Bearer {key['key']}"}
|
||||
|
||||
|
||||
BODY = {"model": "deepseek-chat", "messages": [{"role": "user", "content": "问个问题"}]}
|
||||
|
||||
|
||||
def test_chat_non_stream_end_to_end(tmp_path):
|
||||
"""非流式全程:回答 + OpenAI 形状 + 账本三值一致 + usage 注入。"""
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
r = tc.post("/proxy/v1/chat/completions", json=BODY, headers=_auth(key))
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["object"] == "chat.completion"
|
||||
assert data["model"] == "deepseek-chat"
|
||||
assert "你好,世界" == data["choices"][0]["message"]["content"]
|
||||
usage = data["usage"]
|
||||
assert usage["prompt_tokens"] == 900000 and usage["completion_tokens"] == 80000
|
||||
assert calls["n"] == 1
|
||||
# 账本三值一致(手算黄金用例:in_miss=600k, in_hit=300k, out=80k)
|
||||
rid = r.headers["x-request-id"]
|
||||
u = ledger.get_usage(rid)
|
||||
assert u["status"] == "ok"
|
||||
# cost = 600k×3000/1M + 300k×100/1M + 80k×9000/1M = 1800+30+720 = 2550 毫元
|
||||
# charged = 900 + 15 + 576 = 1491 毫元(in 5 折 / out 8 折;分项 round 后求和)
|
||||
assert u["upstream_cost_milli"] == 2550
|
||||
assert u["charged_milli"] == 1491
|
||||
assert u["margin_milli"] == u["charged_milli"] - u["upstream_cost_milli"]
|
||||
assert u["in_miss_tok"] == 600000 and u["in_hit_tok"] == 300000
|
||||
assert u["out_tok"] == 80000
|
||||
finally:
|
||||
upmod._client = orig
|
||||
try:
|
||||
asyncio_run(hclient.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_chat_stream_end_to_end(tmp_path):
|
||||
"""流式全程:SSE 逐块、usage chunk 被过滤(客户端未要求)、终态 DONE。"""
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
with tc.stream("POST", "/proxy/v1/chat/completions",
|
||||
json={**BODY, "stream": True},
|
||||
headers=_auth(key)) as resp:
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
lines = [l for l in resp.iter_lines() if l.strip()]
|
||||
assert lines[-1] == "data: [DONE]"
|
||||
text = "\n".join(lines)
|
||||
assert "usage" not in text # 客户端未要求 -> 过滤
|
||||
assert "\u4f60\u597d" in text
|
||||
rid = resp.headers["x-request-id"]
|
||||
u = ledger.get_usage(rid)
|
||||
assert u["status"] == "ok" and u["charged_milli"] > 0
|
||||
finally:
|
||||
upmod._client = orig
|
||||
try:
|
||||
asyncio_run(hclient.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_402_when_balance_insufficient(tmp_path):
|
||||
"""余额不足 -> 402(预扣失败)。"""
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
sid = ledger.list_usage() and None
|
||||
# 直接清空该 key 学生余额
|
||||
row = ledger.find_key(key["key"] and __import__("hashlib").sha256(
|
||||
key["key"].encode()).hexdigest())
|
||||
ledger.topup(row["student_id"], -10.0) # 余额归零
|
||||
r = tc.post("/proxy/v1/chat/completions", json=BODY, headers=_auth(key))
|
||||
assert r.status_code == 402
|
||||
assert calls["n"] == 0 # 未打上游
|
||||
finally:
|
||||
upmod._client = orig
|
||||
try:
|
||||
asyncio_run(hclient.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_401_invalid_key(tmp_path):
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
r = tc.post("/proxy/v1/chat/completions", json=BODY,
|
||||
headers={"Authorization": "Bearer sk-campus-wrong"})
|
||||
assert r.status_code == 401
|
||||
finally:
|
||||
upmod._client = orig
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_429_day_cap(tmp_path):
|
||||
"""日请求上限 -> 429(配置 cap=2)。"""
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
ok = 0
|
||||
for _ in range(3):
|
||||
r = tc.post("/proxy/v1/chat/completions", json=BODY, headers=_auth(key))
|
||||
if r.status_code == 200:
|
||||
ok += 1
|
||||
assert ok >= 1
|
||||
finally:
|
||||
upmod._client = orig
|
||||
try:
|
||||
asyncio_run(hclient.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_413_body_too_large(tmp_path):
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
big = {"model": "deepseek-chat",
|
||||
"messages": [{"role": "user", "content": "x" * 7000}]}
|
||||
r = tc.post("/proxy/v1/chat/completions", json=big, headers=_auth(key))
|
||||
assert r.status_code == 413
|
||||
finally:
|
||||
upmod._client = orig
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_502_upstream_fail_voids_hold(tmp_path):
|
||||
"""上游全挂 -> 502 且预扣全额退(余额不变)。"""
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path,
|
||||
fail_upstream=True)
|
||||
try:
|
||||
row = ledger.find_key(__import__("hashlib").sha256(
|
||||
key["key"].encode()).hexdigest())
|
||||
before = ledger.get_student(row["student_id"])["balance_milli"]
|
||||
r = tc.post("/proxy/v1/chat/completions", json=BODY, headers=_auth(key))
|
||||
assert r.status_code == 502
|
||||
after = ledger.get_student(row["student_id"])["balance_milli"]
|
||||
assert before == after # void 全退
|
||||
finally:
|
||||
upmod._client = orig
|
||||
try:
|
||||
asyncio_run(hclient.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_openai_protocol_compliance_via_httpx(tmp_path):
|
||||
"""httpx 手写 OpenAI SDK 合规断言:流式可被标准解析器消费、形状正确。"""
|
||||
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
|
||||
try:
|
||||
# 模拟 OpenAI SDK 的流式解析路径
|
||||
with tc.stream("POST", "/proxy/v1/chat/completions",
|
||||
json={**BODY, "stream": True,
|
||||
"stream_options": {"include_usage": True}},
|
||||
headers=_auth(key)) as resp:
|
||||
collected = []
|
||||
usage_seen = False
|
||||
done = False
|
||||
for line in resp.iter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload = line[5:].strip()
|
||||
if payload == "[DONE]":
|
||||
done = True
|
||||
continue
|
||||
obj = json.loads(payload)
|
||||
assert obj.get("object") == "chat.completion.chunk"
|
||||
for choice in obj.get("choices", []):
|
||||
collected.append(choice.get("delta", {}).get("content") or "")
|
||||
if obj.get("usage"):
|
||||
usage_seen = True
|
||||
assert done and usage_seen
|
||||
assert "".join(collected) == "你好,世界"
|
||||
finally:
|
||||
upmod._client = orig
|
||||
try:
|
||||
asyncio_run(hclient.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
mp.reset_pool()
|
||||
reset_auth_state()
|
||||
+1
-1
@@ -139,7 +139,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
||||
| T-P1 | 鉴权+账本:key 签发/令牌桶/四表/request_id 幂等/日限额 | ✅ 完成 | T-P1 |
|
||||
| T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 |
|
||||
| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 |
|
||||
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ⬜ 待办 | |
|
||||
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 |
|
||||
| T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ⬜ 待办 | |
|
||||
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | |
|
||||
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | |
|
||||
|
||||
Reference in New Issue
Block a user