Files
projectAIpopular/gateway/proxy/upstream.py
T
tzt f24f016e95 feat(proxy): T-P2 上游客户端(流式派发/usage 注入过滤/三家归一化/首 token 前 failover)
- upstream.py:httpx.AsyncClient 模块级单例(keepalive,limits=100,
  超时 connect10/read120/write10/pool30);stream() 始终注入
  stream_options.include_usage(计量不依赖客户端)+ filter_usage_chunk
  (客户端未要求 usage 时剥除该 chunk);D-P4 首 token 前 failover 链、
  流中失败抛 UpstreamAborted(不可切换);ttfb_ms 记录
- normalize_usage 三家归一:deepseek prompt_cache_hit_tokens /
  openai prompt_tokens_details.cached_tokens / anthropic cache_read_input_tokens
  (命中数>总数时钳制)
- model_pool:+provider(枚举校验 deepseek/openai/anthropic)+in_hit_price
  (缺省 = price_in×1/30,D-P3)
- 测试 +6:透传+sink/failover/流中 aborted/全挂 502/三家归一/过滤,全量 345 passed
2026-09-05 09:14:31 +08:00

140 lines
5.7 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.
"""上游客户端(T-P2):流式派发 + usage 注入/过滤 + 三家归一化 + 首 token 前 failover。
锁定决策:
- D-P4:仅在上游**首 token 返回前**允许切换备用条目;流中失败 = aborted(由调用方
按已收 usage 计费),本模块以 UpstreamAborted 标记。
- §5.1:代理向上游始终注入 stream_options.include_usage(计量不依赖客户端行为);
客户端未要求 usage 时,透传层过滤该 chunk 不下发(routes 的 tee 负责,见 filter_usage_chunk)。
- httpx.AsyncClient 模块级单例(keepalivelimits.max_connections=100),
超时 connect=10s / read=120s / write=10s / pool=30s。
"""
from __future__ import annotations
import json
from typing import Any, AsyncIterator, Dict, List, Optional
import httpx
from gateway.proxy.errors import UpstreamError
_TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=30.0)
_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)
_client: Optional[httpx.AsyncClient] = None
def get_client() -> httpx.AsyncClient:
"""模块级单例(keepalive 连接池)。"""
global _client
if _client is None:
_client = httpx.AsyncClient(timeout=_TIMEOUT, limits=_LIMITS)
return _client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
class UpstreamAborted(Exception):
"""首 token 已下发后上游失败(D-P4:不可 failover,按已收 usage 计费)。"""
def normalize_usage(provider: str, usage_dict: Dict[str, Any]) -> Dict[str, int]:
"""三家 usage 字段 -> 统一 {in_miss, in_hit, out}。
- deepseekprompt_cache_hit_tokens / prompt_tokens
- openai 兼容:prompt_tokens_details.cached_tokens
- anthropiccache_read_input_tokens / input_tokens
"""
usage = usage_dict or {}
out = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
total_in = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
if provider == "deepseek":
hit = int(usage.get("prompt_cache_hit_tokens") or 0)
elif provider == "anthropic":
hit = int(usage.get("cache_read_input_tokens") or 0)
else: # openai / 兼容端点
details = usage.get("prompt_tokens_details") or {}
hit = int(details.get("cached_tokens") or 0) if isinstance(details, dict) else 0
hit = min(hit, total_in)
return {"in_miss": total_in - hit, "in_hit": hit, "out": out}
def _inject_usage_option(body: dict) -> dict:
"""浅拷贝注入 stream_options.include_usage(§5.1 计量不依赖客户端行为)。"""
shaped = dict(body)
shaped["stream_options"] = {"include_usage": True}
return shaped
def filter_usage_chunk(raw_line: str, client_wants_usage: bool) -> Optional[str]:
"""透传过滤:客户端未要求 usage 时剥除 usage 字段所在 chunk(返回 None = 丢弃)。"""
if client_wants_usage or not raw_line.startswith("data:"):
return raw_line
payload = raw_line[5:].strip()
if payload == "[DONE]":
return raw_line
try:
obj = json.loads(payload)
except json.JSONDecodeError:
return raw_line
if obj.get("usage"):
return None
return raw_line
async def stream(body: dict, entry: Dict[str, Any], usage_sink: Dict[str, Any],
fallback_entries: Optional[List[Dict[str, Any]]] = None
) -> AsyncIterator[bytes]:
"""流式派发(§6 签名扩展 failover 链)。
- 始终注入 include_usageusage chunk 交给 usage_sink(透传过滤由调用方用
filter_usage_chunk 决定)。
- D-P4:首 token 前(未 yield 任何字节)失败 -> 切换 fallback_entries
已 yield 后失败 -> 抛 UpstreamAborted。
- usage_sink["usage"] 收敛为归一化 dictsink["ttfb_ms"] 记录首字节耗时。
"""
import time as _time
candidates: List[Dict[str, Any]] = [entry] + list(fallback_entries or [])
last_err: Optional[Exception] = None
for cand in candidates:
provider = str(cand.get("provider") or "openai")
url = cand["base_url"].rstrip("/") + "/chat/completions"
headers = {"Authorization": f"Bearer {cand['api_key']}"} if cand.get("api_key") else {}
payload = _inject_usage_option(body)
t0 = _time.perf_counter()
first = False
try:
client = get_client()
async with client.stream("POST", url, headers=headers, json=payload) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
raw = line
payload_txt = line[5:].strip()
if payload_txt == "[DONE]":
yield (raw + "\n\n").encode("utf-8")
continue
try:
obj = json.loads(payload_txt)
except json.JSONDecodeError:
continue
if obj.get("usage"):
usage_sink["usage"] = normalize_usage(provider, obj["usage"])
if not first:
first = True
usage_sink["ttfb_ms"] = int((_time.perf_counter() - t0) * 1000)
yield (raw + "\n\n").encode("utf-8")
return # 正常结束
except Exception as exc: # noqa: BLE001
if first:
raise UpstreamAborted(str(exc)) from exc
last_err = exc
continue # 首 token 前失败 -> failover
raise UpstreamError(f"上游均不可用: {type(last_err).__name__}: {last_err}")