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:
tzt
2026-09-05 09:35:12 +08:00
parent a0afeec5cb
commit 5b04276fc8
4 changed files with 598 additions and 13 deletions
+2
View File
@@ -209,6 +209,8 @@ try:
_proxy_cfg = build_proxy_config(settings_store().to_dict()) _proxy_cfg = build_proxy_config(settings_store().to_dict())
if _proxy_cfg.enabled: if _proxy_cfg.enabled:
app.include_router(build_proxy_router(_proxy_cfg, get_pool())) 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 - 代理层装配失败不拖垮主应用 except Exception as _pe: # pragma: no cover - 代理层装配失败不拖垮主应用
print(f"[gateway] 代理层未启用({_pe}") print(f"[gateway] 代理层未启用({_pe}")
+305 -12
View File
@@ -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-Keyhmac.compare_digest;未配置仅 loopback)。
"""
from __future__ import annotations 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.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: def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
"""组装代理面路由(唯一组装点pool 为 gateway.model_pool.PoolStore)。""" """组装代理面路由(唯一组装点)。"""
router = APIRouter(prefix="/proxy") router = APIRouter(prefix="/proxy")
ledger = __import__("gateway.proxy.ledger", fromlist=["Ledger"]).Ledger.init_db(cfg.db_path)
# ---------------- 学生面 ----------------
@router.get("/v1/models", tags=["proxy"]) @router.get("/v1/models", tags=["proxy"])
async def list_models(): async def list_models():
"""学生面:池内允许代理的模型列表(OpenAI /models 形状)。""" """学生面:池内允许代理的模型列表(OpenAI /models 形状)。"""
entries = pool.list().get("entries", []) 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(), [] seen, data = set(), []
for n in names: for e in entries:
if n not in seen: if not e.get("enabled") or e.get("backend") in ("mock",):
seen.add(n) continue
data.append({"id": n, "object": "model", "owned_by": "campus-proxy"}) 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} 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 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 按字符/3out 按 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})
+290
View File
@@ -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 拿 ledgerrouter.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
View File
@@ -139,7 +139,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T-P1 | 鉴权+账本:key 签发/令牌桶/四表/request_id 幂等/日限额 | ✅ 完成 | T-P1 | | T-P1 | 鉴权+账本:key 签发/令牌桶/四表/request_id 幂等/日限额 | ✅ 完成 | T-P1 |
| T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 | | T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 |
| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 | | 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-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ⬜ 待办 | |
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | | | T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | |
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | | | T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | |