lili e8f7dc8347 feat(worker): WU2 §3.7 worker 取 job.userToken + §3.9 额度耗尽/凭据失效分辨
per-user 额度按用户扣的最后一环:三 worker 入口从「读全局 env NEWAPI_KEY」改「优先
job.userToken、缺失回落全局 env key(WARN 脱敏)」;新失败因 quota_exhausted 干净失败。

§3.7 F(取 job token,默认关时字节不变):
- cheap_service_driver._resolve_key(user_token) 优先 per-user token、缺失回落 env+WARN;
  drive_cheap_generation 从 job.userToken 取、透传给凭据体;token 日志脱敏(前后各 4 位)。
- worker_service 整 job(含 userToken)透传给 driver;do_POST 记 userToken 存否(脱敏)。
- wg1 _client.get_api_key/get_client/chat 支持 user_token override、回落 env(WARN 一进程一次)。

§3.9(分辨两类失效,one-api 惯例初值,确切 message 阶段2 待验):
- result_out 新增 quota_exhausted 入枚举 + _map_failure_reason 认 summary.failureReason +
  classify_newapi_failure_reason/newapi_error_status_text(402/403→quota,401→llm_error 可重试)。
- cheap_service_app collector 崩溃路 best-effort 分辨 402/403→写 sidecar failureReason,
  driver→result_out 落 quota_exhausted;凭据失效/其他维持 llm_error,纯旁路不咬生成。
- wg1 _client.chat 额度耗尽抛 NewapiQuotaExhaustedError(不重试);service.handle_job 映射
  quota_exhausted。注:两 worker 主生成经 AgentScope 模型封装调网关,402 埋框架内,主路
  额度耗尽分辨待阶段2 拦模型封装;本次覆盖直连 _client.chat 路 + cheap 崩溃路分辨。

自证:test_result_out 25/25、test_worker_service 17/17(stdlib 直跑);driver/wg1 token 与
分辨逻辑 standalone 断言全过;八文件 py_compile 绿。test_cheap_service_driver 需 pytest
(本机未装),已同步桩签名待阶段2 e2e 真验(per-user token 调网关/used_quota 按用户增/耗尽落 quota_exhausted)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:44:08 -07:00

234 lines
12 KiB
Python
Raw Permalink 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.

"""_client.py —— W-G1 L1 裸路模型客户端(复用 spike 代理旁路 + 裸 openai 客户端 + usage 读取)。
L1 纪律spike VERDICT §3/§6裸 llm_client不引 AgentScope框架 token 膨胀+本地开销吃便宜档单价)。
唯一模型出口 = new-api OpenAI 兼容网关BASE_URL/v1绝不直连厂商 SDK密钥只走环境变量/本地 .env绝不入库。
"""
import os
import time
from pathlib import Path
from urllib.parse import urlparse
# ── 本地 .env 加载wg1/gen-worker/.envgitignored密钥外置不落代码 ──
_ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
def _load_env():
"""把 wg1/gen-worker/.env 里的 KEY=VAL 注入环境(已存在的不覆盖)。"""
if not _ENV_PATH.exists():
return
for line in _ENV_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
# 网关地址(可被环境变量覆盖;默认 new-api Tailscale 网关 OpenAI 兼容路径)。
BASE_URL = os.environ.get("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1")
# L1 目标模型(可被 WG1_MODEL 覆盖;冒烟默认最便宜档 deepseek-v4-flash
DEFAULT_MODEL = os.environ.get("WG1_MODEL", "deepseek-v4-flash")
# ── 代理旁路spike VERDICT §4-坑1必须在 import openai 前)──
# 本机 clash 代理(HTTP(S)_PROXY=127.0.0.1:7897) 会把发往 100.64.0.8 的请求经代理转发→502
# httpx/openai 默认 trust_env=True须把网关 host 并入大小写两个 NO_PROXY 才直连。
_GW_HOST = urlparse(BASE_URL).hostname or "100.64.0.8"
for _k in ("NO_PROXY", "no_proxy"):
_cur = os.environ.get(_k, "")
if _GW_HOST not in _cur:
os.environ[_k] = (_cur + "," + _GW_HOST) if _cur else _GW_HOST
import openai # noqa: E402 —— 必须在代理旁路之后导入
# WU2 §3.7:回落全局 env key 时打 WARN;但 get_api_key 每次 chat 都会被调,故一进程只 WARN 一次防刷屏。
_ENV_FALLBACK_WARNED = False
class NewapiQuotaExhaustedError(RuntimeError):
"""new-api 网关按 per-user token 判额度耗尽(WU2 §3.9)。
据 one-api 惯例:HTTP 402/403 或错误体含额度耗尽关键词。属**不可重试**干净失败——worker 据此回调
failure_reason=quota_exhausted(而非含糊 llm_error);凭据失效(401)不塌成本错误、维持可重试 llm_error。
【阶段2 待验】402/403 与确切错误体 message S0 只坐实管理面、未测 /v1/chat/completions 耗尽错误体,
确切匹配待 S4 真机耗尽/凭据失效测试;现按 one-api 惯例初值分辨(状态码优先、关键词兜底)。
"""
def _mask_token(tok) -> str:
"""token 日志脱敏(WU2 §3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打凭据。"""
if not tok:
return "<none>"
s = str(tok)
return "****" if len(s) <= 8 else f"{s[:4]}{s[-4:]}"
def _newapi_error_status_text(exc):
"""从 new-api 调用异常尽力抽 (http_status, 错误体文本);抽不到 status 返 None,文本恒兜底 str(exc)。
兼容 openai SDK 的 APIStatusError(.status_code / .response.status_code / .body / .message)与被上层
框架包装后的普通异常(只剩 str)。供 classify_newapi_failure_reason 分辨。
"""
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(exc, "code", None)
if status is None:
resp = getattr(exc, "response", None)
status = getattr(resp, "status_code", None) if resp is not None else None
parts = []
for attr in ("message", "body"):
v = getattr(exc, attr, None)
if v:
parts.append(str(v))
parts.append(str(exc))
return status, " ".join(parts)
def classify_newapi_failure_reason(status=None, text: str = "") -> str:
"""据 one-api 惯例把网关非 2xx 分辨成失败因(WU2 §3.9)。
返回 'quota_exhausted'(额度耗尽,不可重试)或 'llm_error'(凭据失效 401/其他,可重试、不塌成额度耗尽)。
判据:HTTP 402/403 → 额度耗尽;401 → 凭据失效;状态码取不到时按错误体关键词兜底。
【阶段2 待验】确切 message/code 需 S4 真机耗尽/凭据失效测试坐实;此处状态码优先、关键词兜底为初值。
"""
try:
s = int(status) if status is not None else None
except (TypeError, ValueError):
s = None
if s in (402, 403):
return "quota_exhausted"
if s == 401:
return "llm_error" # 凭据失效:维持可重试,不塌成 quota_exhausted(§3.9 ②)
t = (text or "").lower()
quota_markers = ("insufficient user quota", "insufficient quota", "余额不足", "额度不足", "quota not enough")
if any(m in t for m in quota_markers):
return "quota_exhausted"
return "llm_error"
def get_api_key(user_token=None):
"""解析本次调用的 new-api 凭据(WU2 §3.7 F)。
优先用传入的 per-user token(user_token,随 §6.1 job 派来);缺失回落全局 env NEWAPI_KEY(缺失即抛,
密钥不入库、复现前先设 .env 或 export)。回落是系统/编排/bake-off/spike 触发的正常预期(无 game_player
成员身份、后端 dispatch 已按身份旁路不塞 token);额度门在后端 dispatch 层按身份堵,worker 层只做「有则用、
无则回落」。回落时打一条 WARN(一进程一次,防每次 chat 刷屏)便于发现本该带 token 的 member create job 异常。
"""
if user_token:
return user_token
key = os.environ.get("NEWAPI_KEY")
if not key:
raise RuntimeError("NEWAPI_KEY 未设:请在 wg1/gen-worker/.env 写入或 export NEWAPI_KEY=...")
global _ENV_FALLBACK_WARNED
if not _ENV_FALLBACK_WARNED:
_ENV_FALLBACK_WARNED = True
print(f"[wg1-worker] 无 per-user userToken → 回落全局 env NEWAPI_KEY({_mask_token(key)})。"
f"系统/编排/bake-off/spike 为正常预期;若为真实 member create job 则异常走共享计量(§3.7)。",
flush=True)
return key
def get_client(user_token=None):
"""构建指向 new-api 的裸 OpenAI 兼容客户端(WU2 §3.7:凭据优先 per-user token、缺失回落 env)。"""
return openai.OpenAI(api_key=get_api_key(user_token), base_url=BASE_URL, max_retries=2, timeout=180.0)
def _extra_body(model, enable_thinking):
"""按模型构造 extra_bodyMiniMax 系走官方 thinking 控制(默认 disabled关思维链防截断其他模型不带厂商专有参。"""
if "minimax" in (model or "").lower():
return {"thinking": {"type": "adaptive" if enable_thinking else "disabled"}}
return {}
def _cache_hit_from_usage(usage):
"""从 openai 原始 usage 抽命中缓存的 prompt token兼容两套字段取 maxU4/B9
DeepSeek `usage.prompt_cache_hit_tokens` / MiniMax `usage.prompt_tokens_details.cached_tokens`,取 max
字段全缺/异常 → 0按 miss 计,不报错)。逻辑与 cost.cache_hit_tokens 同口径,但本处只依赖 openai 原始对象、
不反向 import cost避免 _client→cost 循环依赖cost 已 import _client
"""
if usage is None:
return None
try:
cands = []
ds = getattr(usage, "prompt_cache_hit_tokens", None)
if ds is not None:
cands.append(int(ds))
details = getattr(usage, "prompt_tokens_details", None)
cached = getattr(details, "cached_tokens", None) if details is not None else None
if cached is not None:
cands.append(int(cached))
return max(cands) if cands else 0
except Exception:
return 0
def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0,
enable_thinking=False, user_token=None):
"""单次裸调用 chat.completions。确定性产出 temperature=0瞬时网关错误(502 burst)重试。
返回 dict{content, prompt_tokens, completion_tokens, prompt_cache_hit_tokens, total_tokens, model, wall_s, id, finish_reason}。
usage 字段用 openai 2.41.1 口径prompt_tokens / completion_tokens供成本折算与 token 取证);
prompt_cache_hit_tokensU4/B9 新增additive 键)= 命中缓存的 prompt token两套字段取 max缺则 0供 cost.py 折缓存价。
WU2 §3.7user_token 给定则用它调网关(per-user 额度)、缺失回落全局 env key。
WU2 §3.9:网关非 2xx 按 one-api 惯例分辨——额度耗尽(402/403)立即抛 NewapiQuotaExhaustedError 不再重试;
凭据失效(401)/瞬时错(502)维持原重试语义(不塌成额度耗尽)。
"""
client = get_client(user_token)
last_err = None
for attempt in range(tries):
try:
t0 = time.perf_counter()
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=temperature,
max_tokens=max_tokens,
stream=False,
# 关推理默认MiniMax-M3 在 OpenAI 端点默认 thinking=adaptive(开),思维链内联进 content复杂局
# 飙 token → max_tokens 处截断、永远到不了 JSONfinish_reason=length表现为"parse 失败",实测
# match3/simon。官方关法(MiniMax docs)= 顶层 thinking={type:disabled};本 worker 只要结构化 JSON、
# 不需 CoT故对 MiniMax 系默认关enable_thinking=True 可覆盖)。非 MiniMax 模型不带此厂商专有参。
extra_body=_extra_body(model, enable_thinking),
)
wall = time.perf_counter() - t0
choice = resp.choices[0]
usage = resp.usage
return {
"content": choice.message.content or "",
"finish_reason": getattr(choice, "finish_reason", None),
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
# U4/B9命中缓存的 prompt tokenadditive 键;旧 dict 消费方不受影响,新口径供 cost 折缓存价)。
"prompt_cache_hit_tokens": _cache_hit_from_usage(usage),
"total_tokens": getattr(usage, "total_tokens", None),
"model": model,
"wall_s": round(wall, 3),
"id": getattr(resp, "id", None),
}
except Exception as e: # 瞬时网关错误重试spike 实测 new-api 偶发 502 burst
# WU2 §3.9:额度耗尽(402/403)重试无意义,立即抛典型错误让 worker 回调 quota_exhausted;
# 凭据失效(401)/瞬时错(502)不塌成额度耗尽,维持下方重试语义(可重试 llm_error)。
if classify_newapi_failure_reason(*_newapi_error_status_text(e)) == "quota_exhausted":
raise NewapiQuotaExhaustedError(
f"new-api 额度耗尽(按 §3.9 分辨,阶段2 待精确 message){type(e).__name__}: {e}") from e
last_err = e
if attempt < tries - 1:
time.sleep(retry_delay)
raise RuntimeError(f"模型调用失败({tries} 次重试后):{last_err}")
if __name__ == "__main__":
# 自测:列模型(零 token 成本)确认 客户端+代理旁路+密钥 三件齐活。
c = get_client()
ids = sorted(m.id for m in c.models.list().data)
print(f"[_client] BASE_URL={BASE_URL} NO_PROXY 含网关={_GW_HOST in os.environ.get('NO_PROXY','')}")
print(f"[_client] 可用模型 {len(ids)} 个:")
for i in ids:
print(" ", i)