PROPOSAL.md 定方向,本文定实现。只描述当前代码现状。
| 层 | 选型 | 版本 | 说明 |
|---|---|---|---|
| 运行时 | Python | 3.12(requires-python >=3.12;CI 3.12,Dockerfile 3.14-slim) |
|
| 包管理 | uv(venv + pyproject.toml + uv.lock) | 见 uv.lock |
|
| Web | FastAPI + Uvicorn | 0.143.3 / 0.54.0 | |
| 配置 | pydantic-settings | 2.16.0 | Settings 为 frozen |
| HTTP | httpx 双客户端(流式/短请求分离);httpx[socks] 提供按渠道 SOCKS5 |
0.28.1 / socksio | 共享 trust_env=False |
| 数据库 | 标准库 sqlite3(WAL)+ 手写 SQL | 内置 | |
| 加密 | cryptography Fernet | 50.0.2 | |
| 测试 | pytest + pytest-cov + respx + 文件化 fixture | 9.1.1 / 7.1.0 / 0.23.1 | |
| 前端 | React 19 + Tailwind 4 + shadcn/ui + @lobehub/icons + Vite 8 + TypeScript 7 | 见 web/package.json |
小版本随依赖漂移,以
pyproject.toml/uv.lock/web/package.json为准;仅主版本或兼容边界变化时回改本表。
行内列出该目录全部文件;职责按「/」与文件顺序对应。
| 路径 | 文件 | 职责 |
|---|---|---|
pyproject.toml |
— | uv 项目;[tool.pytest.ini_options] 设 coverage 目标 |
| 仓库根 | Dockerfile、docker-compose.yml |
compose build context 依赖根目录 |
src/ |
main.py config.py runtime_settings.py version.py |
组装·lifespan·路由挂载/pydantic-settings 全部 env + live() 归一化/运行时配置覆盖层(DB > env + 白名单 + snapshot)/版本号(pyproject.toml 真源) |
src/webapp/ |
limits.py security.py request_id.py access_log.py log_buffer.py handlers.py logging.py static.py |
请求体上限 ASGI(登录 8KB / 其余 16MB)/Host 白名单 + CSP·nosniff/请求 ID(最外层,含 413/400)+ 会话 contextvar/请求级 logfmt/日志环形缓冲 + redact_or_drop/异常 → 稳定错误码/root logger/SPA catch-all(排除 /api、/v1) |
src/db/ |
schema.sql conn.py migrate.py repo.py limits.py crypto.py backup.py |
DDL(只加不改)/线程本地连接 + WAL + busy_timeout + transaction()/迁移(补列之后建索引)/持久化手写 SQL/限额 CRUD + 当日聚合/Fernet/备份恢复(VACUUM INTO + integrity_check + 0600) |
src/auth/ |
users.py bootstrap.py session.py api_key.py csrf.py throttle.py rbac.py access.py |
DbUserStore + users.txt 导入/引导(导入 → ADMIN_USERNAMES 提权 → 无活跃 admin 报错)/会话 HMAC(含 session_epoch)/sk- 摘要 + 常量时间校验/CSRF/登录三级限流 + PBKDF2 并发上限/三角色/IP 与模型白名单(纯函数) |
src/provider/ |
base.py proxy.py naming.py token_expiry.py openai_chunk.py fixtures/ |
Provider 协议·Event·ErrKind·Quota·HealthScore/按渠道代理(PROVIDER_PROXIES)/模型名归一/到期提取(expires_at → JWT exp)/OpenAI chunk 共享原语/真实样本 |
src/provider/codebuddy/ |
client.py intl.py events.py credential.py headers.py oauth.py checkin.py activity.py growth.py growth_runner.py refresh.py |
上游 + SSE + 额度探测 + 风控指纹清洗/国际版子类(覆渠道 id 与能力 None)/SSE → Event + 分类/凭证解析/常量与头/设备码/签到/活跃上报/成长中心(15 端点)/成长编排(7 类领取)/刷新 + 多账号切换 |
src/provider/trae/ |
client.py events.py credential.py pricing.py backfill.py callback.py |
上游 + 额度/自定义 SSE → Event/凭证解析 + 原子写回 + 签到设备号/单请求积分推算/历史回填/登录 URL + 回调解析 |
src/provider/zen/ |
client.py events.py |
上游 + 门禁伪装 + 注入工具回包过滤/标准 SSE → Event |
src/provider/kilo/ |
client.py events.py |
上游(标准 OpenAI)+ isFree 过滤(不探活)/SSE → Event(delta.reasoning) |
src/provider/qoder/ |
cosy.py client.py events.py credential.py auth.py |
COSY(自定义 Base64、AES/RSA、签名与会话头缓存)/上游 + 信封解包 + 额度·签到/信封 → chunk → Event/凭证解析/设备码 PKCE |
src/provider/codearts/ |
signer.py dpop.py client.py events.py credential.py auth.py oauth.py |
SDK-HMAC-SHA256(纯函数)/ES256 DPoP(低 S)/上游 + 私有 SSE 还原 + 积分额度/SSE → 增量 Event(双形状)/凭证解析/OAuth2 PKCE/回调粘贴换 token |
src/provider/officeace/ |
signer.py client.py events.py credential.py oauth.py |
双签名(V11 + SDK-HMAC,纯函数)/上游 + 积分·签到·费率/错误分类 + max_tokens 改写/两套凭据 + 一次性 refresh/云端授权轮询(PKCE + DPoP + 开通校验) |
src/engine/ |
scheduler.py executor.py limits.py inflight.py affinity.py continuation.py compress.py model_resolver.py sse.py |
选号 + 冷却状态机 + pin/执行 + 目录收窄 + 轮换 + fallback + 统计埋点/限额判定 + 快照缓存/在途租约/会话粘性/截断续写/上下文压缩/模型名解析/SSE 帧解析 |
src/compat/ |
openai/{request,response,errors}.py responses/{request,response}.py anthropic/{request,response}.py |
Chat 校验·聚合·error shape/Responses 出口/Anthropic Messages 出口 |
src/tasks/ |
pacer.py quota_probe.py checkin.py growth.py activity.py refresh.py retention.py backup.py alerting.py upstream_status.py model_probe.py status.py runner.py |
全局节流器/额度探测/签到(CHECKIN_TIMES 空则每 10 分钟)/成长(仅 CB)/活跃上报/预刷新/小时汇总 + 90 天清理/备份/告警(四规则 + webhook)/上游状态聚合/模型探活/TASK_SPECS + 运行态/调度接入 lifespan |
src/stats/ |
backfill.py collector.py live.py query.py csv.py |
历史回填/明细写入 + 小时双写 + 提交后广播/进程内实时广播/各类统计查询/CSV(公式注入防护) |
src/ 顶层 |
sorting.py benchmarks.py pricing.py model_match.py request_context.py upstream_status.py |
sort/order 白名单/OpenRouter 目录(价 + 元数据 + 能力分 + 快照)/成本估算(USD→CNY)/模型名匹配/请求 ID contextvar/状态页聚合 |
src/api/ |
deps.py endpoints.py chat.py responses.py messages.py context.py models.py model_merge.py model_catalog.py balance.py authorize.py admin_credentials.py admin_keys.py admin_settings.py admin_users.py admin_audit.py admin_alerts.py admin_badges.py admin_upstream.py admin_model_catalog.py admin_model_availability.py logs.py activate.py admin_stats.py admin_auth.py playground.py streaming.py metrics.py |
Services 容器 + 鉴权依赖/dispatch_compat 骨架/三个 /v1 出口/压缩接线/GET /v1/models(SWR + 落盘 + 白名单)/归一·合并·别名(纯函数)/目录快照/余额/TRAE 回调/凭证运维/Key CRUD/配置与任务/用户/审计/告警/角标/上游状态/模型目录 + 探活/激活/统计/登录 + 上游登录/Playground/SSE 包装//metrics |
src/audit/actions.py |
— | 审计动作常量 + 中文标签 |
web/ |
— | React 前端。.npmrc 的 auto-install-peers=false 与 lock 的 settings.autoInstallPeers 强耦合(避免拉 @lobehub/ui/antd 子树);Dockerfile 必须 COPY web/.npmrc,否则 ERR_PNPM_LOCKFILE_CONFIG_MISMATCH |
deploy/ |
— | launchd / newsyslog / logrotate / systemd 模板 |
diagrams/ |
— | 架构图 / 时序图 / 状态机(HTML + 源 JSON) |
scripts/ |
— | 建号、回填、运维脚本 |
tests/ |
— | 按 src/ 子包镜像 |
class EventKind(StrEnum):
CONTENT = "content" # 正文增量
REASONING = "reasoning" # 思考链增量
TOOL_CALLS = "tool_calls" # 工具调用增量(上游原样,index 由 engine 补齐)
USAGE = "usage" # prompt/completion/reasoning/cached tokens;credit 可空
FINISH = "finish" # finish_reason
ERROR = "error" # 流内业务错误(provider 解析时写 error_kind)
@dataclass(slots=True)
class Event:
kind: EventKind
content: str | None = None
tool_calls: list[dict] | None = None
usage: Usage | None = None
finish_reason: str | None = None
error_code: int | None = None
error_message: str | None = None
error_kind: ErrKind | None = None # 流内错误的分类,executor 直接消费
@dataclass(slots=True)
class Usage:
input_tokens: int | None = None
output_tokens: int | None = None
reasoning_tokens: int | None = None
cached_tokens: int | None = None # 输入中命中缓存的 token(缺省 None)
credit: float | None = None # 上游可选字段,两边都经常为 None
credit_estimated: bool = False # credit 是否为本服务推算(True 时展示标 ≈)- 事件名过滤:TRAE 的
metadata/timing_cost/extra_info/progress_notice等无语义事件,不在KNOWN_EVENT_NAMES内直接跳过。 - 解析失败不静默:已知事件里 JSON 非对象 / 字段类型错 →
UpstreamProtocolViolation,不退回默认值。 - tool_call 续块必须保留:TRAE 按
index分片,首片带function_call.name,续片只有arguments。只丢「无 name 且 arguments 为空」的噪声(_is_blank_solo_tool_call);按「无 name 即丢」会把续片吃光 → 客户端拼出截断 JSON → 死循环。provider/openai_chunk.py的is_blank_tool_call同一判据。
class ErrKind(StrEnum):
PLAN = "plan" # 权益耗尽(1005)→ 冷却 12h
CREDIT = "credit" # 余额不足(402/14018)→ 冷却到次日 04:00(等签到恢复)
SOFT = "soft" # 限流/404 → 冷却 60s,不累计错误数
DEAD = "dead" # session 失效 → 硬禁用 disabled=1
OTHER = "other" # 其他 4xx/5xx → 累计,连续 3 次 → 冷却 10m
INVALID = "invalid" # 请求无效(模型不存在等)→ 不冷却凭证,跳过该上游;全拒 → 400
MODEL = "model" # 该模型用量超限(429+6004)→ 只冷却 (凭证, 模型),10m 起翻倍封顶 2h
CONCURRENCY = "concurrency" # 并发会话打满 → 固定 60s 模型级短冷却,不翻倍
BLOCKED = "blocked" # 该账号无此模型(400/404+11102)→ (凭证, 模型) 负缓存,6h 起翻倍封顶 24h
REQUEST = "request" # 请求级错误(11101/11115/11128/11135)→ 零动作:不冷却、不累计,仅换号
QUEUE = "queue" # 排队中(isQueued / RESOURCE_LIMIT)→ 零动作:等 retryAfterSeconds 后原号重开
MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY})MODEL_SCOPED_KINDS 只写 credential_model_cooldowns(§6.3),不碰账号级 cooling_until。反之账号级冷却出现时清空该凭证的模型级条目,否则切模型能绕过账号级限流。
排队语义(QUEUE):上游明确回「排队中 + 建议等待秒数」时,既不是账号故障也不是请求错误,而是「这个号现在满了,等 N 秒再打它」。处理口径:
- 不冷却:
scheduler.note_error对 QUEUE 零动作(与 REQUEST 同); - 不换号:
executor用pinned记住被点名的凭证,等完在原号上重开; - 不占轮换预算:排队轮次不计入
attempts,否则三次排队就把max_rotate吃满、请求直接 503; - 有界:
QUEUE_MAX_ROUNDS = 3轮、单轮封顶QUEUE_MAX_WAIT_SECONDS = 60s; - 已出帧不重开:半截输出不可回滚,降级为 SOFT 收尾。
识别在 provider.base.parse_queue_hint(信封可嵌套,最多下钻 3 层):isQueued: true / subCode ∈ {RESOURCE_LIMIT, RESOURCE_LIMIT_EXCEEDED} 任一成立即认定。宁可漏判不可误判——识别不出就走原有分类,绝不把普通失败当排队无限等。Qoder 的 _decode_frame 必须在 classify_error_code 之前拦下:上游把排队也包成 403,按状态码分类会判成 DEAD 硬禁用凭证。Event.retry_after_seconds 承载上游点名的秒数。
| 上游信号 | CB | TRAE | Kilo | ErrKind |
|---|---|---|---|---|
| 权益耗尽 | code=1005 |
code:1005 |
— | PLAN |
| 余额不足 | 402 / code=14018 |
402 / code=14018 |
— | CREDIT |
| 模型级限流 | 429 + code=6004 |
429 + code=6004 |
429 / 502/503/504(点名模型) | MODEL |
| 并发打满 | — | — | — | CONCURRENCY(CodeArts TM.00001041) |
| 该账号无此模型 | 400/404 + code=11102 |
400/404 + code=11102 |
— | BLOCKED |
| 请求级错误 | 400 + 11101/Unmarshal chat params failed/11115/11128/11135 |
— | 403 | REQUEST |
| 限流 | 429(无 6004) | 429(无 6004) | — | SOFT |
| 不存在 | 404 | 404 | — | SOFT(CodeArts 404 亦 SOFT) |
| 会话失效 | 401/403 | 401 | — | DEAD |
| 服务端错误 | 5xx | 5xx | 500 | OTHER |
| 请求自身无效 | 400 | 400(含 4001) | 400/404/422 | INVALID |
| 排队中 | — | — | — | QUEUE(Qoder isQueued / RESOURCE_LIMIT) |
| 流内错误事件 | SSE error 事件 | event:error 业务码 |
{"error":{...}} 帧 |
同上映射(单一来源 Event.error_kind,缺失回落 OTHER) |
- zen / kilo 的 401 →
INVALID:两者无凭证,401 Missing API key.只说明该模型要付费 key;归DEAD会让一次强制付费请求硬禁用整条免费渠道。 - kilo 429 与 502/503/504 →
MODEL:免费池是 OpenRouter 共享池,报错点名具体模型(limit_source: upstream_provider_shared_pool),同时刻其他免费模型仍可用。 - CodeArts 临时凭证过期 →
SOFT:HTTP 400+APIG.0602 security token has expired,识别在401/403 → DEAD之前(_STS_EXPIRED_MARKERS)。判DEAD会让RefreshTask跳过该凭证,断掉唯一自愈路径。
业务码只从 "code": N 键值形态提取(provider.base.business_codes):裸搜数字会让 "code":111020 命中 11102。判定顺序即优先级,429+6004 先于 429,402/14018 先于状态码兜底。
内容风控指纹(11128):400 + 11128 Illegal API invocation from an unapproved channel → REQUEST。主因是 system/assistant 正文出现「伪装其他厂商官方客户端」的指纹串(身份声明行、billing 头字段名、git 上下文行)。特征:与凭证无关、确定性复现、只在这两个角色的 content 命中(tool_calls 与 reasoning 不触发);一旦写进历史,后续每轮都 11128。处置:出站前 sanitize_channel_markers 替换为占位符(CODEBUDDY_SANITIZE_CHANNEL_MARKERS=false 关闭),客户端历史不受影响;文本块列表逐块处理。清单在 provider/codebuddy/client.py::CHANNEL_MARKERS,此处刻意不写原文以免污染阅读本文件的会话。
@dataclass(slots=True)
class Quota:
remaining: float | None = None
total: float | None = None
cycle_end: int | None = None # 最早到期 epoch;无到期信息为 None
expiry_ladder: list[tuple[int, float]] | None = None # [(到期 epoch, 该包剩余积分)]
packages: list[dict] | None = None # [{"name","total","used","end"}],仅展示
probed_at: int | None = None
probe_failed: bool = False
UNKNOWN: int | None = None
EXHAUSTED = -1
# HealthScore:0-100 已知;None 未知;-1 已耗尽
def health_score(q: Quota | None) -> int | None:
if q is None or q.probe_failed:
return UNKNOWN
if q.total is None or q.total <= 0:
return EXHAUSTED
return max(0, min(100, round((q.remaining or 0.0) / q.total * 100)))排序:known DESC > unknown > exhausted(-1)。probe_failed 与「探测到 0」必须分开——Zen/Kilo 虚拟凭证恒 probe_failed=True。CB 个人版 remaining 取逐包 CycleCapacityRemainPrecise 之和,不用顶层 TotalDosage:后者是上周期口径,会出现 remaining == total、健康度恒 100、与到期阶梯自相矛盾。企业版走 limitNum - credit。
finish_reason == "length" 时同凭证续写,最多 AUTO_CONTINUE_MAX(默认 10,0 关闭)。实现为 engine/continuation.py::ContinuationStream:追加「已产出正文 + 续写指令」重发,输出上限两键归零,跨轮累计 usage,末端补一条累计 usage + 最后一轮真实 finish_reason(上游未报 usage 时不补,避免误报 0)。做成事件流包装器而非 executor 重跑:凭证固定,轮换 / 记账 / 统计零改动。
只做 length 一个判据:「仅 reasoning 无正文」只在客户端下发 max_tokens 时出现,而本网关不做 max 键映射,不可达;「空正文」「代码块未闭合」无证据,不做。
输出上限键:CB 忽略 max_completion_tokens、只认 max_tokens(精确截断 + length);两键同发后者胜。TRAE 两键都不生效。enable_thinking:false 亦被 CB 忽略。
reasoning_effort 缺失补 medium:不发该字段时 CB 把思考写进 delta.content(reasoning_content 恒空、reasoning_tokens 恒 0)。stream_chat 用 setdefault,不覆盖显式值。
(engine/affinity.py)优先级:① 请求体 conversation_id > conversationId > prompt_cache_key(同键名先 metadata 再顶层)→ ② 请求头 x-session-id / x-opencode-session / x-session-affinity / conversation-id / conversation_id(首个合法值,须匹配 [A-Za-z0-9._:-]{1,128})→ ③ 消息增量前缀指纹。
- 头经
request_context的 contextvar 传入(RequestIdMiddleware设置、请求结束复原),键带header:前缀避免与 body 撞键。 - 带
user_id(顶层或metadata)时不派生前缀兜底键(头照常生效),否则同用户并行对话会被误钉到同一凭证。 - 键里掺用户名(头值同样掺,头是客户端可写输入,不掺就能靠猜占用他人账号)。
- TTL
CONVERSATION_STICKY_SECONDS内存态,重启只影响一轮选号;条目上限 512,超量先淘汰过期再按最旧。
| 字段 | CB 来源 | TRAE 来源 | 取值 |
|---|---|---|---|
supports_reasoning |
supportsReasoning |
display_config.model_capability(reasoning_model→true、chat_model→false),缺失回落 reasoning_effort_config.support_thinking |
true / false / null |
default_effort |
reasoning.effort |
无对应字段 | CB: high / medium;TRAE 恒 null |
supported_efforts不实现:上游只给默认档不给清单。CB 的onlyReasoning/canDisableThinking同样不透传(语义未核实)。- 唯一例外是 Playground 入参控件(
web/src/lib/playgroundParams.ts):档位候选固定 OpenAI 三档(low/medium/high),上游default_effort不在其中则追加;supports_reasoning !== true且无default_effort时不渲染,默认「不发送」。三个高级参数留空即不发键,前端校验边界与compat/openai/request.py对齐(temperature≤ 1000、max_tokens1–4e6)。 - 逐字段补缺:多上游同名模型先到先填,后到只补 None。
- 黑名单(
MODEL_BLOCKLIST,fnmatch glob,只影响展示,直连不受影响)。默认只展示当前在售新模型,三类滤掉:① 上游内部/代理模型——custom_model_*/*sub*agent*/summary/browser_use_*/file_search_agent,及各渠道自动路由占位default/default-model/fast-model/balanced-model/primary-model/deep-model/auto;② 已被取代的老模型——glm-4.*/glm-5/glm-5.0*/glm-5.1/glm-5.2*/glm-5v-*/glm-5-turbo、kimi-k2.*、minimax-m2.5/m2.7/m3、deepseek-v3-*/deepseek-v4-*(v4.1不受影响)、qwen3.7-*、gpt-5.4/gpt-5.5、hy3*/hy4-preview、hunyuan-*、seed-2.1*/seed-code-pro-*/seed-evolving、qwen3-vl-235b、nemotron-3*/lfm2*/laguna*/north-mini-code/inkling-small/xing4.0-29b-a4/openrouter-models-router;③ 实测不可用——glyph-cluster(Kilo 121s 后 408)、qwen3.8-flash(Qoderqfmodel400,CB/TRAE 的qwen3.8-max不受影响)。在售主力刻意不加:deepseek-v4.1-flash/kimi-k3/glm-5.3*/mimo-v2.6*/qwen3.8-max/openpangu-2.0-*。 - 按归一后的对外写法匹配(
_block_names):原代号、原代号归一键、展示名、展示名归一键四种取并集(默认规则带下划线,归一键把下划线换成连字符后不命中);清洗后为空的名字不参与。 - 非模型级问题不进黑名单:CodeArts
InferHub.4291.200 insufficient quota与TM.00001041是账号级;Kilopoolside/laguna-xs-2.1/nemotron-3-nano-omni的 429·502 与thinkingmachines/inkling-small的limit_rpd会自行恢复。
src/api/responses.py + compat/responses/{request,response}.py。与 chat 共用同一 executor,唯一改动是 Executor.stream(..., translator=...) 注入缝。字段形状取自官方 openai SDK(3.x),并用该 SDK 做端到端契约验证。
| Responses | chat |
|---|---|
instructions |
首条 system |
input 字符串 |
user 消息 |
input[].type=message(input_text/output_text) |
messages[];developer 改写为 system |
input[].content[].type=input_image |
image_url part(detail 丢弃);纯文本折成字符串 |
input[].type=function_call |
assistant tool_calls[] |
input[].type=function_call_output |
tool 消息(part 可含 input_image) |
input[].type=reasoning |
assistant reasoning_content(仅明文) |
tools[].type=function(扁平) |
嵌套 {type:function, function:{...}} |
tool_choice 字符串 / {type:function,name} |
同名 / 嵌套 |
max_output_tokens |
max_tokens |
reasoning.effort |
reasoning_effort |
text.format |
response_format(委托 normalize_response_format,prefix="text.format" 只改报错路径) |
temperature / top_p / parallel_tool_calls / prompt_cache_key / text.verbosity |
同名透传 |
出站序列(流式):response.created → output_item.added(message / reasoning / function_call)→ content_part.added → output_text.delta × N → output_text.done → content_part.done → output_item.done → response.completed | incomplete。思考用独立 reasoning item(response.reasoning_summary_text.delta/.done),工具用 function_call item(response.function_call_arguments.delta/.done)。无 [DONE] 哨兵,终止事件即流结束(done_sent 对齐)。finish_reason=length → response.incomplete(max_output_tokens),content_filter 同理;流内错误 → response.failed(no_healthy_credential 映射成 Codex 认识的 server_is_overloaded)。
取舍:include=["reasoning.encrypted_content"] 接受并忽略(400 会打死 Codex CLI),其他 include 值 400;previous_response_id 与 store=true 保持 400(无状态);event: 与 data.type 双发;回传历史里的 reasoning/compaction 只有密文,有意丢弃;其余私有 item(local_shell_call/custom_tool_call)显式 400。未经真实 Codex CLI 端到端验证(开发环境无 CLI)。
POST /v1/messages + /v1/messages/count_tokens,同模式复用 executor。
system(字符串或 text 块)→ 首条system;空则丢。messages[].content字符串直传;块数组时text→ 文本、tool_result→tool消息(tool_use_id→tool_call_id,内含图片同样归一)、image→image_url(base64拼data:{media_type};base64,{data},url直传)、document/source.type=file→ 显式 400。user 连续块按类型拆多条,assistant 块合并一条(thinking→reasoning_content、tool_use→tool_calls、redacted_thinking丢弃)。纯文本仍折成字符串,无图请求映射结果与加图片支持前逐字节一致。tools[].input_schema→function.parameters;tool_choiceauto/none直传、any→required、tool→ 嵌套;stop_sequences→stop;max_tokens/temperature/top_p直传;top_k丢弃。cache_control原样透传(默认开、无开关):system块、user/assistant 块、tool_result(挂 tool 消息顶层)、assistanttool_calls元素、tools元素。只校验结构(对象 + 非空type,ttl若给须字符串),取值不解释。无标记时映射逐字节不变。实测 6/8 渠道接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo),zen 因FreeTierError无法验证、OfficeAce 未跑探针(scripts/probe_cache_control.py)。
出口翻译(response.py,实现 StreamSink):message_start → content_block_start/delta/stop(text_delta / thinking_delta / input_json_delta)→ message_delta(stop_reason + usage)→ message_stop。无 [DONE];thinking 块在 content_block_stop 前补占位 signature_delta(本网关不产真签名)。错误走 event: error。
心跳:StreamSink.keepalive() 由出口协议各自实现,dispatch_compat 把它交给 with_keepalive 作空闲填充帧。Anthropic 返回具名事件 event: ping + data: {"type": "ping"}(官方规范;只解析 event: 行的客户端会忽略注释帧);chat / Responses 返回 SSE 注释帧 : keepalive——Responses 不能改 ping,该协议无此事件类型,严格解析器遇未知事件直接报错。
非流式保活(NONSTREAM_KEEPALIVE_SECONDS,默认 0 = 关闭):stream=false 的请求在聚合完成前一个字节都不发,CDN / 反代的空闲超时会直接切连接。api/streaming.py::respond_with_keepalive 采用宽限期设计——先等 N 秒:
- 期间完成 → 返回普通
JSONResponse,状态码 /Content-Length/ 字节内容与不开保活完全一致,异常照常抛给异常处理器(400/503 不变); - 超时 → 才提交 200 + chunked,先吐
b"\n"再补正文。
空白字节合法:RFC 8259 §2 规定 JSON-text = ws value ws,值前允许任意空白,故 "\n\n{...}" 仍是合法 JSON(Python json.loads / JS JSON.parse 均已实测接受)。为什么不一开始就用 StreamingResponse:那样 200 在聚合前就发出去了,之后任何失败都改不回来;宽限期把「改不回来」的窗口压到只有真正超时的请求。代价:提交 200 之后才失败只能把错误写进正文(LATE_FAILURE_PAYLOAD),因此默认关闭——本部署 nginx proxy_read_timeout 3600s + frp TCP,链路上没有短空闲超时,30 天实测 >60s 的成功请求 1741 个全部成功。
usage 缓存字段:内部 input_tokens 是 OpenAI 口径(含缓存命中),Anthropic 语义互斥。故命中时 input_tokens = max(0, prompt_tokens − cached_tokens) 并另记 cache_read_input_tokens;命中为 0/None 不补占位。
鉴权:先 x-api-key(ANTHROPIC_API_KEY),空则回落 Authorization: Bearer(ANTHROPIC_AUTH_TOKEN),共用 _api_key_principal。Base URL 填到根,SDK 自拼 /v1/messages。count_tokens 用本地启发式估算,不转发。
CC Switch 深链(web/src/lib/ccSwitch.ts):ccswitch://v1/import?resource=provider&…,app = claude/codex/opencode。endpoint 形态随 apiFormat:anthropic 填根不带 /v1,openai_chat/openai_responses 填 /v1。深链含 Key 明文,只在用户主动操作时生成(创建成功对话框 / 每行「导入 CC Switch」,后者需粘贴完整 Key,仅存内存)。需 CC Switch v4.0.10+。无 Gemini 出口。
动机:上游输入硬限(CB 11115 prompt is too long),换号无意义。只做确定性「估算 → 裁剪」,不做超限后压缩重试。纯函数 engine/compress.py + 装配闭包 api/context.py。
- 估算:中文 0.55 tok/字、数字 0.33、其他 0.25(英文「3 字符 ≈ 1 token」会把中文低估 1.6 倍)。图片 part 按 URL/base64 长度计入(按 100 字符估会让图片会话永不触发压缩)。
- 预算 =
模型上限 × safety_ratio − reserve_for_output。 - 裁剪:
system永久保留;assistant.tool_calls与其后连续tool结果同生共死;至少留最近min_keep_messages条;其外从新到老贪心回填。仍超限则截断最长非 system 消息(留头尾 + 标记)。 - 未知上限不压缩:目录查不到
max_input_tokens直接跳过,零副作用。 - 声明值 ≠ 可用量:TRAE 声明 200000/232768,实测 1,450,000 字符(约 1,035,544 token)仍可读,1,470,000 才报
4022/4026。故_CHANNEL_WINDOW_FLOOR按渠道抬到实测下限(TRAE1_048_576),只抬不压,且只影响压缩预算(/v1/models仍宣告声明值)。TRAE 的context_window_tokens有{dev, max}两档,dev是保守档:取max(max > dev时),否则 1M 上下文的模型会被报成 200K。 - 按实际渠道取值:压缩在 executor 选号之后执行(
ExecutorDeps.context_compress);路由前只能取 min 口径,会反复误裁大窗口渠道。每轮从原文重压;affinity 指纹读未压缩原文。
热更:CONTEXT_COMPRESS_ENABLED(true)/ CONTEXT_COMPRESS_RESERVE_TOKENS(4096)/ CONTEXT_COMPRESS_MIN_KEEP_MESSAGES(4)/ CONTEXT_COMPRESS_SAFETY_RATIO(0.95)。
| 列 | 取值 | 空值语义 |
|---|---|---|
provider_binding |
见 KNOWN_PROVIDERS |
空 = 跨渠道 |
allowed_ips |
逗号分隔 IP/CIDR | 空 = 不限 |
allowed_models |
fnmatch glob、逗号分隔 | 空 = 不限 |
expires_at |
epoch 秒 | NULL = 永不过期 |
- 渠道绑定:只在绑定渠道选号;模型属他渠道直接 400 并指出归属;
模型@渠道冲突同样 400。执行在Executor._apply_binding,与@provider共用收窄路径并置forced(跳过目录收窄)。目录未就绪时保守放行。 - 来源 IP:
deps.api_key_user当场判。默认不采信X-Forwarded-For;仅TRUST_PROXY=true时取最后一个条目。写入时normalize_allowed_ips规范化(10.0.0.1→10.0.0.1/32);读取宽松,整份白名单解析不出则拒绝(fail closed)。失败 403。 - 模型白名单:三个 /v1 出口解析后校验;空名 /
auto按default_model判定(不能借空名绕过);大小写不敏感,@渠道后缀不参与;/v1/models同样过滤。 - 到期:过期与「Key 不存在」统一 401 文案。
只提供机制不给策略:三项默认关 / 留空。内置默认矩阵(哪个渠道认哪些档位)一律不做(未实测即编造)。
① FORCE_DISABLE_THINKING(默认 false):响应侧剥离,过滤点是 executor 消费事件的循环(_stream_loop 与 _complete_model 聚合前),三个出口一致生效。刻意不放进 _stream_source:ContinuationStream 在那层内,续写要靠 reasoning 原文,剥在外层才既保质量又对客户端隐藏。只过滤响应不改请求体(CB enable_thinking:false 被忽略,不发 reasoning_effort 反退化成可见推演)。usage.reasoning_tokens 不受影响。
② REASONING_EFFORT_MAP(默认空):格式 渠道=from:to,from:to;*=from:to,目标写 none 即删键。应用点在选号之后(_map_effort,provider_id 已知)。只动客户端显式值(渠道 setdefault 在 provider 侧、晚于此处)。需改写时返回新 dict,不改 request.raw(后者是粘性指纹输入)。
③ ANTHROPIC_THINKING_EFFORTS(默认空):budget_tokens → reasoning_effort,格式 预算:档位,…,disabled:档位;取「阈值 ≤ budget 的最大档」,低于最小阈值不映射。留空 = 完全不映射。Anthropic 只规定 budget_tokens ≥ 1024、未给对应表,故阈值必须显式配置。未知 thinking.type 与非法预算不映射。结果写入 ChatRequest.raw,会再经 ② 映射。
解析宽容:engine/reasoning.py 忽略空段 / 缺分隔符 / 非整数阈值,一行笔误不连累其他条目,不抛异常。
class Provider(Protocol):
id: str
# 凭证生命周期(上游协议私有,必然在 provider 内)
def start_auth(self, callback_url: str) -> AuthSession: ... # flow=poll|callback|paste
def poll_auth(self, state: str) -> AuthResult | None: ... # 仅 poll 轨道
async def complete_callback(self, raw_url: str, state: str) -> dict: ... # 仅 TRAE
def import_credential(self, raw: dict) -> dict: ...
def refresh(self, credential_data: dict) -> dict: ...
# 额度探测(调度器依赖:健康度 + 到期阶梯)
async def probe_quota(self, credential_data: dict) -> Quota: ...
# 执行与分类
async def stream_chat(self, credential_data: dict, payload: dict,
model: str) -> AsyncIterator[Event]: ...
def classify(self, status: int, body: bytes) -> ErrKind: ...
def list_models(self, credential_data: dict) -> list[Model]: ...
async def aclose(self) -> None: ...与
src/provider/base.py严格一致,改一处必须同步另一处。
- 凭证以 dict 传递(解密后的
data_enc);credential_from等能力还原为私有 dataclass,provider 不接触 sqlite。 stream_chat只产Event,产出前先查 HTTP 状态码;classify由 executor 调用。- 可选能力不实现即不定义(不抛
NotImplementedError):调用方getattr探测,缺失回 400「该凭证不支持此操作」。当前可选集:start_auth/poll_auth、complete_callback(仅 TRAE)、list_accounts/switch_account(仅 CB)、credential_from/checkin_scope、checkin/checkin_status、growth(仅 CB)、host。 - CodeBuddy / TRAE / Zen / Kilo / Qoder / OfficeAce 共用
engine/sse.py帧解析;CodeArts 的累计全文形状自实现iter_data_lines,只复用MAX_SSE_LINE_BYTES/SSEFrameTooLarge。 - Zen 与 Kilo 最薄:只实现
import_credential/classify/probe_quota/list_models/stream_chat/aclose,签到 / 成长 / 刷新靠方法缺失自然跳过。
标准 OpenAI 流式(chat.completion.chunk + [DONE])。events.py 很薄,唯一要点是最后一段正文与 finish_reason 同帧,parse_all_events 需同帧产出 CONTENT 与 FINISH。[DONE] 后跟的 {"choices": [], "cost": "0"} 跳过。
渠道私有的只有门禁伪装(zen/client.py):
| 条件 | 要求 | 违反后果 |
|---|---|---|
User-Agent |
opencode/<version>,version ≥ 1.18.0 |
低于阈值 → 426;无版本号 → 403 |
x-opencode-session |
^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$,前 12 位 hex 须解码回近期时间戳 |
403 |
body stream |
必须 true |
403 |
body tools |
必须同时含 bash 与 read(参数可空壳) |
403 |
Authorization |
Bearer public 可带可不带 |
带伪造 key → 401 |
会话前缀编码照官方 Identifier.create(sst/opencode src/id/id.ts):ms * 0x1000 + 1,按位取反,取低 48 位写 12 位小写 hex;后 14 位自由。复刻见 new_session_id / _session_time_prefix。门禁在鉴权之前:带真实 oc_sk_* 反而 401。
- UA 走
ZEN_OPENCODE_VERSION,端点走ZEN_API_ENDPOINT(受ZEN_ALLOWED_ENDPOINTS校验)。Zen 不携带任何用户 Token,白名单只为防误配。 - 403 FreeTierError 抖动客户端内消化:403 归
REQUEST(不冷却不跳渠道),但 zen 只有一条虚拟凭证,轮换立刻耗尽 → 503。故stream_chat退避GATE_403_RETRY_DELAY(1s)后换新 session id 重发一次(GATE_403_RETRIES=1),仍 403 才上抛;其余状态码不重试。 - 注入空壳 + 改写/过滤回包:门禁要求
bash/read,可能是我们伪造的(客户端执行工具叫shell)。① 客户端有同义工具(TOOL_SYNONYMS:bash→shell/execute、read→read_file)→ 把注入名的 tool_call 改写成同义工具透传,注入时复制其 description/parameters;② 无同义工具 → 整块丢弃并记 WARNING。只在缺失时补骨架(用户自带同名工具则不动);按index跟踪流式分片;若整条流无保留的真 tool_call,把finish_reason=tool_calls收敛为stop。 - 免费清单完全动态:
/zen/v1/models免鉴权但返回全部模型且无免费标记(owned_by恒opencode,无 cost)。唯一权威信号是匿名可用性(付费恒 401Missing API key.)。两步过滤:-free后缀收窄 → 并发探活只留 2xx。无静态白名单;探活为空抛协议错,让列表用上次缓存兜底。判活缓存 30 分钟(MODELS_CACHE_TTL_SECONDS,比服务层 300s 长一档)。免费模型标credit_rate=0.0(非None)。
虚拟凭证行:credential_data={}、added_by="system",复用调度 / 冷却 / 统计 / 粘性。probe_quota 恒 Quota(probe_failed=True) → health None(未知,不是 EXHAUSTED)。种子幂等,删除后重启复活,永久停用用「暂停」。只在默认装配路径种子。
标准 OpenAI 兼容,无私有信封、无门禁。events.py 与 Zen 同薄,区别是思考在 delta.reasoning(另带 reasoning_details,忽略)。同帧 finish 处理,并补 usage(常与收尾 choices 同帧)。
- 免费有权威标记故不探活:
/models每条带isFree(含无:free后缀的kilo-auto/free、stealth/space-bunny-alpha)。探活会白耗极小配额(网关级约 200 req/h/IP)且结果波动。过滤为空抛协议错,列表用缓存兜底。 - 元数据透传:
name/context_length/top_provider.max_completion_tokens/supported_parameters/architecture.input_modalities。免费模型标 x0。 - 虚拟凭证行同 Zen。错误分类:401 / 400 / 404 / 422 →
INVALID(401 只表示该模型需付费 key / BYOK),403 →REQUEST,429 与 502/503/504 →MODEL。限流交引擎,本包不自建熔断。 - 独立 pacer
KILO_CHAT_MIN_INTERVAL(热更,默认 0),finally里pacer.release("kilo")。
真实账号渠道,凭证必须登录获得、加密入库,不种虚拟凭证。私有 COSY 协议:
- 推理
POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation。请求体是自定义 Base64 变体(三段轮转 + 自定义字母表 +=→$);认证Authorization: Bearer COSY.<payload_b64>.<md5sig>+ 整套cosy-*头。 - 签名
md5(payload_b64 + "\n" + cosy_key + "\n" + date + "\n" + body + "\n" + path)(path去/algo,query 不参与)。payload是键排序紧凑 JSON{cosyVersion,ideVersion,info,requestId,version};cosy_key由临时 AES 密钥经服务端 RSA 公钥加密,info是 AES-128-CBC 加密的身份 JSON。复用cryptography。 cosy-machineid/cosy-machinetoken按uid稳定派生(随机机器码触发风控)。- 信封式 SSE:
data:{"headers":…,"body":"<内层 OpenAI chunk>","statusCodeValue":200}。逐帧解body,body=="[DONE]"结束,statusCodeValue != 200判上游错误(HTTP 仍可能 200)。clean_delta剔除空噪声 delta。 - 模型发现
GET {gateway}/algo/api/v2/model/list?Encode=1,必须带整套 COSY 头(签名 body 为qoder_encode(""));裸 GET 403,方法固定 GET(POST/PUT 400)。id用key(如qmodel_38max)、name用display_name。price_factor即 credit 消耗倍率(相对值,免费给0.0)。 - 签到(活动制):
GET /sash/api/v1/me/campaigns(必须带Cosy-ClientType: 10,否则静默空列表)→ 筛actionType=="CLAIM_BENEFIT"且claimStatus=="CLAIMABLE"→POST .../{campaignId}/claim(同样带头)。replayed:true与BLOCKED+SAME_PERSON_ALREADY_CLAIMED归一为已签,其它BLOCKED才是失败。新协议无连续天数(streak_days恒 None)。仅活动制接口 404/405/410 时回退旧daily-check-in;两者都不可用记 skipped(ok=False但不算失败)。国际版该端点 404。 - 签到窗口 ≠ 自然日:上游每日 10:00(UTC+8) 轮换
campaignId,窗口10:00 → 次日 09:59。按自然日封账会在凌晨封掉整天,故CheckinResult.seal_until回填checkin_window_end(payload)(签到类活动里最晚的endAt,不采信VIEW_DETAILS)。CheckinTask._done_scopes为dict[done_key → 截止时刻],判据_sealed()(until > now)。 - 额度
GET {openapi}/api/v2/quota/usage:userQuota+addOnQuota合成remaining/total,cycle_end取最早expiresAt。 - 节点故障归类:Qoder 把自身节点故障包成 400(
{"code":"400","message":"[FAIL]node:… msg:Execution failed: null"},HTTP 与statusCodeValue都是 400)。classify_error_code对 400/404/422 加响应体判据:命中NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")→ErrKind.MODEL,否则INVALID。乱码模型名不带该标记,不误伤。全候选只因该模型冷却时 503 文案用model 'x' temporarily unavailable on upstream。 content: null必须归一为空串:Qoder 拒收带tool_calls的 assistant 把content置为null(OpenAI 允许)。上游把这条消息当「没有正文」跳过,紧随其后的role:"tool"就成了孤儿,报文却是Messages with role 'tool' must be a response to a preceding message with 'tool_calls'—— 指向 tool 配对,真实原因在 assistant 的 null content。出站前build_chat_body的_normalize_null_content把 assistant/tool 的null改成""(语义等价)。2026-10-23 实测:同一请求体只把null换成""即 400 → 200,与reasoning_content/ 并行调用 / 轮数无关;codebuddy_intl、trae、officeace 不敏感。- 白名单
QODER_ALLOWED_ENDPOINTS含国内 openapi+gateway 与国际版,启动时校验;QODER_CHAT_MIN_INTERVAL(热更,默认 5s)独立 pacer。
SDK-HMAC-SHA256:AK/SK + X-Security-Token,Authorization: SDK-HMAC-SHA256 Access=<AK>, SignedHeaders=…, Signature=…。三处逐字节对齐:signedHeaders 为全部头(小写、字典序);CanonicalURI 每段 percent-encode 且末尾补 /(漏尾斜杠是 APIG.0301 常见原因);payload hash 取 X-Sdk-Content-Sha256。signer.py 为纯函数(不含当前时间)以便固定向量测试。
DPoP 刷新(刚性):POST {sts}/v1/oauth2/tokens grant_type=refresh_token 需带 ES256/P-256 JWS。refresh_token 与 client_id=codearts-agent + DPoP 私钥三者绑定、一次性——刷后必须回写新 refresh_token(RefreshTask 先落库再同步账号)。ES256 用 cryptography(ec.SECP256R1 + ECDSA(SHA256))并需低 S 归一化(s > n/2 取 n - s;cryptography 实测 2000 次有 991 次 high-S)。私钥随 refresh_token 持久化复用。
刷新节流:REFRESH_SKEW_HOURS 默认 24h ≫ STS 寿命 2h,故 CodeArtsCredential.refresh_skew_cap_seconds = 2700,needs_refresh 取 min(skew, cap)。窗口必须宽于轮询周期(REFRESH_INTERVAL_MINUTES 默认 30):窗口 ≤ 周期时整轮落在窗口外,会拖到到期才刷(实测 30min 封顶 + 60min 周期 → 距到期 26s 才刷,上游回 APIG.0602)。45min 封顶 > 30min 周期,实际在剩余约 30min 处刷。
SSE 双形状:逐行 data: JSON(也有裸 JSON 行),data:[DONE] 结束。v2 /api/v2/chat/completions 是标准 OpenAI chunk(增量在 delta,收尾帧 choices:[] + usage,tool_stream:true 时分片在 delta.tool_calls)。旧形状 {"text":"<累计全文>"} 为替换语义,用 TextSnapshot 做差(新文本以旧为前缀则只发后缀),结束帧 {"text":"[DONE]","error_code":"0"}。错误两条路:HTTP 非 2xx,或流内 error_code(ChatAgent.* / TM.00001041,HTTP 仍 200)。业务码是字符串,Event.error_code 只有 int 槽位,故原始码进 error_message、分类进 error_kind。
签到:三个端点挂在 snap 引擎根、不带 snap-manager(/v1/statistics/plugin、/v1/current/user 反之必须带):
| 用途 | 端点 | 备注 |
|---|---|---|
| 查活动 | GET {snap}/v1/ops/delivery?channel=IDE |
回 data.items[] |
| 领取 | POST {snap}/v1/ops/claim |
body {campaignId:1, idempotentKey:"claim_1_<ms>", channel:"IDE"} |
| 确认 | POST {snap}/v1/ops/confirm |
body {campaignId:1} |
idempotentKey 格式 claim_<campaignId>_<Date.now()>;头 agent-type: PromptCenter。四态区分:ELIGIBLE → claim + confirm;CLAIMED(已领未确认)→ 只补 confirm;CONFIRMED/CONSUMED → 已签。claim 时积分即到账,confirm 只推进状态,故 confirm 失败不丢积分但仍返回 ok=False 让下轮补。活动不存在 / 已过期 ok=True;ELIGIBLE 但 claimable=false 或未知 status → ok=False。业务码走 HTTP 200(如 40001)。seal_until 留空。checkin_scope 用 uid。签到之外的活动操作未接入。
额度=积分(parse_credits):GET {snap}/snap-manager/v1/statistics/plugin(前缀必须带 snap-manager,头 Agent-Type: PromptCenter)回 metrics[],usageTotalPackageCredit 是合计、其余(Basic/OnDemand/Bonus)是分项包。数值本身即积分,不做单位折算;合计进 Quota.remaining/total,分项进 Quota.packages,顶层 end_date(如 2026-11-02)登记进 expiry_ladder。取不到合计项或数值无效 → probe_failed=True(绝不填 0)。官方只给积分系数(GLM-5.2 0.7 / OpenPangu-2.0-Flash 0.32)无公式 → 不猜,逐请求 credit 保持 None。「保活」只由 RefreshTask 承担(一次性 token 两处消费会烧成废票),probe_quota 只读余额。
倍率:GET {snap}/v1/model/builtin(头 Agent-Type: PromptCenter)每条带 credit[],取首档 ratio_display(如 "0.7x")。空列表按协议违规上抛。
登录:OAuth2 PKCE → POST {snap-manager}/v1/oauth2/tokens(authorization_code)换 {access_key_id, secret_access_key, security_token, expiration, refresh_token},DPoP 私钥随 credential 生成并加密入库。回调 302 到 http://127.0.0.1:{port}/oauth/callback(服务端监听不到)→ paste 轨道:用户把打不开的回调链接粘回,POST /api/auth/upstream/complete 用 code + code_verifier + DPoP 私钥换 token。登录后用 caller_identity(不可用回落 current_user)尽力补 uid/user_name/nickname,失败不阻断。
一次性票终态:client.refresh_token 捕获 TokenEndpointError 后经 events.relogin_required(body) 判定,只认 _RELOGIN_MARKERS(STS5.1806 the refresh token has been used / invalid client id / InvalidDPoPHeader / invalid_grant)。网络 / 5xx / 限流 / 普通 4xx 仍可重试(误判会让偶发故障永久废掉好凭证)。RefreshTask 捕获后 mark_relogin_required:写可操作 disabled_reason、硬禁用、清模型级冷却。
并发上限与滑动窗口:上游每账号每约 60s 最多 3 个会话,超出 HTTP 400 + TM.00001041 并发会话数已达上限(3个)(会话在 HTTP 结束后仍滞留,实测约 68s 恢复)。Pacer 按桶在途上限 max_concurrency(满则挂起等 release)+ 滑动窗口 window_seconds(同桶最近 N 秒最多放行 M 次)。装配 lambda: runtime.codearts_max_concurrency(默认 3)与 lambda: runtime.codearts_request_window_seconds(默认 60s,0 关闭窗口)。窗口模式配 max_concurrency=0 不生效。
名额归还必须显式关流:async for ... break 不关闭 async generator(只在耗尽 / aclose() / GC 时跑 finally)。executor 遇 ERROR 的 break 会让名额推迟归还,_inflight 单调累积 → 表现为「用了三次就限制」。故 provider.base.aclose_stream 统一关闭,stream_guarded / stream / _stream_loop / complete / ContinuationStream.__aiter__ 五处提前结束处全部显式关闭。
白名单 CODEARTS_ALLOWED_ENDPOINTS 含 snap 引擎、STS、门户;CODEARTS_CHAT_MIN_INTERVAL(热更,默认 5s)独立 pacer。
独立渠道而非切 CODEBUDDY_API_ENDPOINT:endpoint 是进程级单值(Settings 单字段),切过去会让存量国内凭证立刻打到国际站(两站独立账号体系,额度与模型清单不通用)。
协议零复制:同一套后端(同一 Keycloak realm copilot、同一组路径 /v2/chat/completions、/v3/config、/v2/plugin/auth/*、/v2/billing/*、/billing/meter/*)。故 CodeBuddyIntlProvider 是 CodeBuddyProvider 的数据类子类(provider/codebuddy/intl.py),只覆渠道 id 与三项能力。路由存在性判据:已注册路径回 401(openresty/APISIX),未注册回 {"error_msg":"404 Route Not Found"};但前缀级挂认证时 401 不带信息(/v2/activity/* 整前缀挂认证),须另找同前缀对照路径。
唯一行为差异:首条 system 强制注入。国际站要求 messages[0] 是 system,否则 400 + {"code":11128,"msg":"first message is not system prompt"}。client 构造参数 require_leading_system 只在国际站开;ensure_leading_system 仅在首条非 system 时补 DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."。三条约束:① 判首条必须在 developer → system 归一之后;② 客户端自带 system 不注入、不覆盖、不重排;③ 正文不带厂商指纹(否则撞 11128)。只改 deepcopy 出的出站副本,不穿透回 request.raw。
能力边界:
- 签到渠道级缺席:路径存在(
/billing/meter/checkin-activity-status回code=0),但活动没有(active: false,season/theme_name/start_time/end_time/activity_name全空;CLAIM 回{"code":10001,"msg":"签到活动未开启或已过期"})。故checkin/checkin_scope/checkin_status三项置None(保留只会每 10 分钟失败一次,触发task_failed噪音)。 - 成长中心未验证:
/v2/activity/*整前缀挂认证,401 无法证明子路由存在,而成长含不可逆动作。growth置None,调用方getattr(..., None)自动跳过;管理台菜单按credential.provider === "codebuddy"判定,天然不出现。 - 活跃上报启用:
/v2/report与/v2/activity/*不同路径,国际站回200 {"code":0}(/v2/report-nope回 404)。activity_scope含 endpoint,两站不互相占位。
模型合并:修渠道内重名导致的伪渠道后缀:上游常共用展示名(hy4-preview / hy4-preview-x 都叫「Hy4 preview」)。改判据:重名时若本渠道存在 id 归一后等于该 label key 的模型,就用该归一键当合并键;无 id 自洽候选时(TRAE 的 sagitta/aquila)仍退回限定键。-x / -sg 是真实不同模型,不可合并。
白名单互不含:CODEBUDDY_INTL_ALLOWED_ENDPOINTS 默认只有国际站;填国内站地址会在启动时抛 ValueError(跨站发送即凭证泄露)。pacer 与 OAuth 各自独立:CODEBUDDY_INTL_CHAT_MIN_INTERVAL(热更,默认 5s)独立 Pacer,_pacer_key 带渠道 id;两渠道各建一套 CodeBuddyOAuth。
两套凭据、两个面、两种签名。聊天面({gateway}/v2/chat/completions、/v2/models)用永不过期的 Authorization: Basic <model_app_key>:<model_app_secret>;控制面(/v1/subscription*、/v1/model-token-rates、/v1/claw/*)用约 2h 的临时 AK/SK + X-Security-Token。has_control_plane 判定控制面能力(只有 app key 的凭证能聊天但不能签到 / 探额度,probe_quota 回 probe_failed 而非 0)。
两条签名轨道不能互换:控制面 /v1/claw/* 用 SDK-HMAC-SHA256(复用 codearts.signer);模型网关的 /v1/subscription* 与 /v1/model-token-rates 只认 V11-HMAC-SHA256(多一层 CredentialScope YYYYMMDD/{region}/apic,密钥要派生)。V11 三处必须原样:① 派生外层 key=AK、msg=SK;② HMAC(HMAC(AK,SK), scope+0x01)[:32] 转 hex 文本当下一轮密钥;③ 不加 X-Sdk-Content-Sha256 头。CanonicalURI 每段 percent-encode 且末尾补 /,query 必须参与签名,X-Project-ID 有则计入 SignedHeaders。写错恒 401 verify ak sk signature fail / APIG.0301。
网关地址归一:client-permission-validate 下发的 api_base 带 /v2,_gateway_of() 统一去掉尾部 /v2 再拼端点(否则 /v2/v2/chat/completions)。每账号一个网关主机,故不进 OFFICEACE_ALLOWED_ENDPOINTS(白名单只管控制面)。
错误分类:限流走伪 200(HTTP 200 + data:{"error":{...}}),且有「首帧正常、第二帧才 error」形态,故不能首帧成功后停检查。81004(无模型权限)/ 81009(模型名不认)→ BLOCKED 模型级负缓存;81111/81112/81114 → 限流;14018 → CREDIT。不做主动探活(耗积分),模型目录按 /v2/models + 实际请求成败收敛。
max_tokens 改写(clamp_max_tokens):>= 16 追加 1024 headroom 再夹到模型上限;< 16 原样;未给不补。max_completion_tokens 归一成 max_tokens。上限表:glm-5* 131072、qwen3* 65536、其余 393216。
积分即主账:单位 officeace_points,Quota.remaining/total 直接是积分。来源:新用户一次性 4000 + 每日签到 1000,各自独立到期(口径写「逐笔到期」)。不做 credit 估算:cost_coefficient 与 tokens_per_point 的叠乘关系官方未说明。签到 POST {gateway}/v1/subscription/bonus/claim 是写操作,body 必须是空 JSON `` + Content-Type: application/json(无 body 回 500 `OfficeAce.11020001`);当日重复领取幂等。收获按 `bonus_skus` 的剩余量增量算(`points` 是活动累计发放量,会虚高)。`seal_until` = 下个北京时间 0 点。`checkin_scope` 用 `account_id`,身份未知返回空串。
登录走 poll 轨道:回调由云端承接(/v1/claw/auth/callback),无需本地端口或粘链接。先在 POST /v1/claw/auth/state 要 state(本地造的回 AgentArts.11000004),把 PKCE challenge + DPoP 公钥拼成 authorize URL,套 {auth_host}/authui/login.html?service=<encode(...)>;轮询 GET /v1/claw/auth/code?state=(202 = 未完成),拿 code 后 POST /v1/oauth2/tokens(authorization_code + DPoP)换临时 AK/SK,再调 client-permission-validate 校验是否真开通(未开通 subscription: null 时明确报错),最后存 model_app_key/model_app_secret 与网关地址。AuthStateStore 按 username 分键做 reservation(带 TTL 与 pending 上限):单槽全局态会让两个管理员并发登录互相覆盖。并发轮询同一 state 只有一路 consume 成功,否则会出现重复凭证。force_login 走 /authui/logout?service=…,默认 False。
配置:OFFICEACE_API_ENDPOINT / OFFICEACE_ALLOWED_ENDPOINTS 只管控制面;OFFICEACE_CHAT_MIN_INTERVAL(热更,默认 0)独立 pacer。登录流量与聊天用独立连接池,共用出站代理。单账号单用户(官方《套餐管理》限制)。
class Scheduler:
MAX_ROTATE = 3
EXPIRY_WINDOW = 36h # 主到期排序窗口,QUOTA_EXPIRY_WINDOW_SECONDS 覆盖;≤0 关闭整套
SECONDARY_EXPIRY_WINDOW = 7d # 次到期排序窗口(主窗口打平时才比较),QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS 覆盖
COOLDOWN = {ErrKind.PLAN: 12h, ErrKind.SOFT: 60s, ErrKind.OTHER: 10m}
ERR_THRESHOLD = 3 # 连续 OTHER 错误 → 冷却
def select(self, candidates, tried: set[str], now: int) -> str | None: ...
def note_success(self, cred_id: str) -> None: ...
def note_error(self, cred_id, kind, now, *, model=None) -> ErrorOutcome: ...排序键(_free_first_rank 起,字典序):
- 免费优先档:候选存在
credit_rate == 0时免费渠道整体排前(None不算免费)。仅当候选带倍率数据才生效。 - 到期额度(两级字典序):
expiry_ladder中「距到期 ≤ 主窗口」(QUOTA_EXPIRY_WINDOW_SECONDS,默认 36h)的额度加总,多者先用;打平比次窗口(默认 7 天);无到期信息计 0;主窗口 ≤0 时次窗口一并失效。CB、TRAE、CodeArts(积分包按end_date)均落阶梯。 - 健康度三态:
known(0-100) DESC > unknown > exhausted(-1)。 - 账户剩余积分多者优先——只作健康度打平键。
- 仍同分按
credential_id稳定。
手动 pin 优先:存在 pinned 时只在 pinned 之间排序。到期积分只算一处 expiring_credits():选号走 Candidate.expiry_credits(),列表走 GET /api/credentials 的 quota_expiring_credits / _secondary,共用 expiry_windows() 折算。无到期信息返回 null(不显示),窗口关闭或确实无临近过期返回 0(同样不显示)。
TRAE 也落
quota_expiry_ladder(权益包各自独立到期),两列同时填,口径「未过期 + 有余额」。quota_cycle_end仍为NULL(各包未必共享重置点)。
Executor._narrow_providers:候选只信模型目录缓存。目录能证明归属时,候选渠道只有它们——全部不可用直接 503,不再逐渠道试(各上游只会回 11102 / 4001 / 401,还会写下数小时负缓存)。目录未就绪或模型不在目录里按全部候选路由;模型@渠道 与 Key 绑定不受影响。新鲜度上限为 TTL 300s(MODEL_LIST_TTL_SECONDS)+ 后台兜底刷新。
credential_model_cooldowns(credential_id, model, cooling_until, hits, reason) 按 (凭证, 模型) 建表——账号级 cooling_until 放不下「同账号其他模型仍可用」。model_probes(...) 是观测记录:冷却表是调度权威(真实失败驱动),探活表只供展示(探测失败未必是模型问题)。
- 登记名是该凭证所属上游的原始模型名(
_upstream_model),CB 与 TRAE 大小写变体不同,归一名会漏判。 - 选号路径:
executor._select逐凭证过滤is_selectable(now, 模型名)并注入Candidate.credit_rate,再交Scheduler.select;同时完成收窄、粘性命中与排序(_sticky(...) or scheduler.select(...))。 - 退避:
MODEL10m 起翻倍封顶 2h;BLOCKED6h 起翻倍封顶 24h;CONCURRENCY固定 60s 不翻倍。换 reason 重新计数。 - 清除:
save_success(..., model=)只删reason='blocked';revive/ 删除凭证 / 账号级冷却出现都清模型条目。留存任务每轮purge_expired_model_cooldowns();列表只下发未过期。 - 无模型名可归因时退化为账号级 SOFT。
- 进入模型级冷却的来源不止模型限流:Qoder 节点故障(§5.3)也归
MODEL。全部候选只因该模型被排除时(_all_model_cooled)503 文案用model 'x' temporarily unavailable on upstream;统计落error_type=model_unavailable,其余耗尽仍no_healthy_credential;对外错误码不变。
热更 MODEL_FALLBACK_GROUPS(默认 "" 关闭):组名=成员1,成员2;组名2=成员3。
- 纯函数层(
model_resolver.py):parse_fallback_groups宽容解析(空段 / 缺=/ 空组名 / 空成员跳过);ordered_fallback_chain只做映射不校验存在性:组名是入口别名、不进链,请求成员置首、其余按配置顺序跟随(大小写不敏感)。 - 执行层:
@渠道与 Key 绑定(target.forced)不参与回退;目录可用时剔除不在任何候选渠道登记的回退成员,未就绪全放行;成员按名去重;链首即请求模型时无条件保留。 - 非流式
complete逐链项走完整选号 / 冷却 / 轮换 / 粘性 / 统计,捕获NoHealthyCredential/InvalidRequest继续,整链耗尽才抛最后一项错误。 - 流式
stream逐链项跑_stream_loop,只有在本项尚未产出任何响应帧前才允许切换(已出帧会混合两个模型输出)。用_ModelExhausted传递「未出帧即用尽」;整链用尽用最后链项的translator产终帧。 preflight按整条链判断是否有注册上游。
credential_limits(credential_id PK, keep_credits, daily_tokens, daily_credits, per_model_tokens JSON, updated_at, updated_by)——缺行 = 不受限。
- 判定(
engine/limits.py::evaluate,纯函数):keep_credits用quota_remaining ≤ keep_credits停整号(余额未知不猜);daily_tokens/daily_credits用当日累计≥ 上限停整号;per_model_tokens只停该模型。None/≤0= 关闭。 - 当日口径:北京自然日 0 点(
ZoneInfo("Asia/Shanghai"))。 - 用量聚合(
db/limits.py::day_usage):按credential_id聚合usage_events(token、credit、按模型 token)。不用usage_hourly(主键无credential_id)。失败请求也计入。 - 挂钩点:
Executor._selectable在is_selectable之后按deps.limits.blocked(...)二次过滤。不写进Candidate.is_selectable(后者是纯函数,塞进去会让pool_counts也查用量)。按模型比对的是归一名。 - 快照缓存:
LimitGuard对「限额表 + 当日用量」做进程内快照(TTL 15s);无配置不查用量。写入后invalidate()立即生效。 - 错误语义:全部因限额跳过 →
no_healthy_credential(_all_model_cooled判据不含限额)。
MAX_INFLIGHT_PER_CREDENTIAL(全局热更,默认 0 不限):达到上限后该凭证不再参与选号。在途计数兼作负载键:Executor._selectable 注入 Candidate.inflight,调度器按「最少连接」排(在免费档之后、到期额度之前)。上限为 0 时 blocked() 恒 False 但租约照常登记。
- 实现(
engine/inflight.py):InflightTracker是credential_id → 活跃租约开始时刻列表的进程内计数;InflightGuard包一层热更上限。租约只记开始时刻、不绑请求标识,先入先出,释放任意一个即可。 - 为什么同步:
acquire/release/count只在事件循环里调用,且「选号 → 登记」之间无await;加异步锁会让_selectable变 async。 - TTL 兜底(
LEASE_TTL_SECONDS = 600):正常由finally释放;漏走时租约滞留,超 TTL 在计数 / 登记时回收。 - 挂钩点:
_selectable在is_selectable/ 限额之后过滤;_stream_loop与_complete_model在创建上游迭代器后_lease,finally里_release。全满时与限额同口径no_healthy_credential。 - 与 pacer
max_concurrency的区别:pacer 桶按渠道分、只覆盖走 pacer 的渠道;这里是跨渠道的凭证级上限,与渠道节流正交。
可视化:
diagrams/coding2api-request-sequence.html。POST /v1/responses同链路:先映射成ChatRequest,其后完全一致,出口形状由 translator 决定。
客户端 → POST /v1/chat/completions (Bearer sk-)
1. deps.require_api_key:摘要查 api_keys 表 → username(+ IP/到期/模型白名单校验)
2. request.py:校验 body → ChatRequest;model_resolver 解析候选集
- "glm-5.2" → 各 provider 都可能;"glm-5.2@trae" → 仅 trae;auto/空 → DEFAULT_MODEL
3. 选号(executor._select → scheduler.select):
a. 候选 = 模型目录能证明归属时收窄到登记了该模型的渠道(_narrow_providers);
收窄后若没有任何可用凭证 → 直接判「无可用凭证」出 503(候选只信模型目录)
b. 模型级冷却过滤:逐凭证按自己所属上游的原始模型名查 (凭证, 模型) 冷却表
c. 会话粘性:存在可选的 pinned 凭证时跳过(pin 优先),否则粘性命中且仍可选的
凭证直接复用、不参与排序
d. 过滤 healthy(enabled=1, disabled=0, 非冷却中)。enabled=0 只作用于本条对话
路径:后台任务只检查 disabled
e. 免费优先档 → 到期额度两级排序 → 健康度三态 → 账户剩余积分 → credential_id
4. executor:解密凭证 → 按实际渠道压缩上下文 → provider.stream_chat()(≤ AUTO_CONTINUE_MAX 续写)
- 上游 HTTP ≥400 → classify → scheduler.note_error → tried 加入 → 回到 3(最多 3 次)
- 流内 Event.ERROR → 同上映射 → 注入出口错误帧 + 冷却 + 轮换
- 上游 400(INVALID,如模型不存在)不冷却凭证,跳过该上游全部凭证;
全拒时 400 invalid_request(未知模型名 ≡ 无上游提供,不走 503)
- REQUEST 类换号但不落库:既不冷却也不累计,否则会顺手把已有的 cooling_until 写成 NULL
5. response.py:Event → OpenAI chunk(流式)或聚合(非流式)
- 首块补 role:assistant;上游无 index 的 tool_calls 补稳定 index,
无 `id` 的补稳定 `id`(`call_{index}_{seq}` 记忆化)——OpenAI 客户端按
`id` 聚合流式分片,缺 id 的分片会被丢弃,表现为工具调用静默消失
- 收尾帧序列:finish chunk → usage 帧(choices: [])→ [DONE];上游未报 usage
则不补该帧(不发 0 占位)。非流式/流式收尾共用 usage_payload()
- 直连 CodeArts v2 每 chunk 都带 usage,取收尾帧真值(取第一个会记成 0/空)
6. stats.collector:写 usage_events(username/provider/model/tokens/latency/ttfb/ok)
7. scheduler.note_success:清 err_count,并把本对话重新粘到实际服务的凭证
客户端断连:生成器关闭 / 取消时统计 error_type=client_disconnect 并关闭上游流;例外是 [DONE] 已产出后的收尾断开(框架在结束帧 more_body=False 前有一拍竞态),按成功记账。
DDL 以 src/db/schema.sql 为准。SCHEMA_VERSION = 29,13 张表:api_keys / credentials / credential_limits / users / audit_events / usage_events / usage_hourly / growth_events / credit_events / credential_model_cooldowns / model_probes / runtime_settings / alert_events。
-- conn.py 打开时执行
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_keys 仍在应用层校验)- 连接:
threading.local()每线程一个sqlite3.Connection(row_factory=sqlite3.Row)。写入统一走Database.transaction(),无应用层写锁,并发由 SQLite 串行化。 - 加密:
Fernet(base64.urlsafe_b64encode(sha256(APP_SECRET).digest()));APP_SECRET 丢失 = 凭证全部不可解。启动时按熵拒弱密钥(长度 ≥16、不同字符数 ≥8,拒重复字符 / 短周期重复串)。 runtime_settings(key, value, updated_at):纯 key/value,只存管理台改过的 key,未出现回落 env;值由HOT_SETTINGS白名单校验,非法行读取时忽略并记日志。- 模型列表与目录不占表:进程内 TTL 缓存 +
DATA_DIR/model_catalog.json/openrouter_catalog.json(§9.4);签到去重由CheckinTask的当日作用域集合实现。
备份与恢复(db/backup.py,任务与 scripts/ 共用):VACUUM INTO 写一致性快照(WAL 下直接 cp 会丢未 checkpoint 的事务),落盘后立即 PRAGMA integrity_check(不过则删半成品并抛错),产物 0600 目录 0700;每 BACKUP_INTERVAL_MINUTES 备份、按 mtime 保留 BACKUP_KEEP 份。恢复 scripts/restore_db.py:校验 → .pre-restore-<ts> 安全副本 → os.replace 原子替换 → 删 -wal/-shm 残留;默认预览,--apply 才写。不做「服务在跑」探测,文档要求恢复前停服、恢复后重启。
凭证标签(credentials.tags,v22;纯函数 src/tags.py):逗号分隔,MAX_TAGS=16 / MAX_TAG_LENGTH=24。写入校验、读取容错——parse_tags 对越界项抛 ValueError(端点转 400),tags_value 只丢弃坏项不抛错。去重大小写不敏感、保留首次写法。半角/全角逗号与分号都当分隔符;含分隔符的列表项写入前被拒。标签不参与调度与鉴权。端点 PUT /api/credentials/{id}/tags(整体替换,require_operator),审计 credential.tags。列表响应 tags 是数组。
迁移纪律(migrate.py):
- 启动时读
schema.sql逐条CREATE TABLE IF NOT EXISTS(只加不改)。 - 新增列写进
_MIGRATION_COLUMNS,走ALTER TABLE ... ADD COLUMN;SCHEMA_VERSION + 1。 - 新增表不需要
_MIGRATION_COLUMNS:建表即完成迁移,只需SCHEMA_VERSION + 1并补老库升级测试。 - 删除表写进
_MIGRATION_DROPS,走DROP TABLE IF EXISTS;SCHEMA_VERSION + 1。 - 版本记在
PRAGMA user_version;库版本高于本程序时启动报错。 - 删除凭证时显式清理其模型级冷却行(无外键级联),否则重建同 id 会继承旧冷却。
- 升级只在启动时发生:迁移与 bootstrap 挂在
build_app,「改了 schema 但没重启」= 老库 + 新前端。
八渠道每个模型统一三字段,规则在 src/provider/naming.py(纯函数,不 import provider 子模块):
| 字段 | 函数 | 例 |
|---|---|---|
原代号 raw_id(渠道请求 key,永不改动) |
— | kmodel_latest、kilo-auto/free、longcat-2.5-preview-free |
| 归一键(合并键 / 多渠道对外 id) | normalize_model_key |
kilo-auto、longcat-2.5-preview、nemotron-3-ultra |
展示名 name |
display_model_name |
LongCat 2.5 Preview、Qwen3.8 Max |
normalize_model_key剥两类上游噪声再 slug 化(空格/下划线/冒号/括号 → 连字符、小写)。免费标记四种形态:尾缀-free/_free、路径段.../free、冒号尾缀:free、括号词(free);同类还有new。命名空间前缀不是身份:路径式取最后一段,冒号式(要求冒号后有空白)削前缀。只删整段/整词标记(freeplay/newson不动)。点号不是分隔(glm-5.2→glm-5-2就换了模型);非 ASCII 字母数字保留。剥完只剩噪声时回退原串小写。display_model_name清洗可读名(Qwen3.8-Max→Qwen3.8 Max)或由 id 派生;品牌按表纠正(机械 title-case 会写错DeepSeek/MiMo/GLM/GPT)。幂等。- 合并键 = 归一键,八渠道统一:同一模型各渠道代号互不相同(Qoder
kmodel_latest= TRAEkimi-k3-1= CBkimi-k3),纯 id 规则无法对齐,只能靠展示名。zen / kilo 也参与合并(Zennemotron-3-ultra-freevs Kilonvidia/nemotron-3-ultra-550b-a55b:free,展示名都清洗成「Nemotron 3 Ultra」)。 - 同模型异名显式规范(
MODEL_SYNONYMS):如 Qoderdfmodel上游名DeepSeek-Flashvs 别家DeepSeek-V4.1-Flash;表把「异名归一键 → (规范归一键, 规范展示名)」。另一类是空格式厂商前缀(Xiaomi MiMo-V2.6-Pro→mimo-v2.6-pro)。只登记人工核实的,不做模糊匹配。 - 哨兵名不跨渠道合并(
_SENTINEL_LABELS=auto/default):语义只在本渠道内成立,退回原 id 的归一键、各自单列。同渠道内归一键重复 → 退回原 id 的归一键,且不登记歧义别名。 - 对外 id 一律归一键:多渠道取最短展示名的归一键;单渠道取该条;兜底/哨兵取原代号的归一键。原代号不露面,转发时经
services.model_aliases映射回(executor.upstream_model_name消费);每渠道登记三条键(对外 id、原代号、展示名及 slug 形式)。 - 撞车消歧(
_disambiguate):按_sort_key序保留先到者,后者依次取_fallback_ids(原代号归一键 → 加渠道后缀 → 数字后缀)。兜底/哨兵条目用_scoped_key(渠道\0归一原代号)隔离,对外 id 仍是原代号归一键。 - 展示顺序:
_PROVIDER_RANK(codebuddy 0 → codebuddy_intl 1 → trae 2 → qoder 3 → codearts 4 → officeace 5 → 其余 6),多渠道取min,同级按归一键字典序。仅影响展示,调度选号顺序(KNOWN_PROVIDERS)不变。
list_models 先用 credential_providers() 求「当前有可用凭证」的渠道(candidates(selectable_only=True):未暂停、未硬禁用;冷却中仍算),provider_id not in connected 直接 continue——没凭证不读缓存、不拉上游、不展示。zen / kilo 有虚拟凭证。
- 失败也进 TTL(负缓存):
model_list_fetched_at记上次尝试(成功或失败)。连续失败按 2^(n-1)×TTL 退避(model_list_failures,封顶MODEL_LIST_BACKOFF_MAX_SECONDS2h,成功清零);日志首次 WARNING、其后 INFO。 - HTTP 出口 SWR:TTL 到期后
/v1/models与/api/playground/models走serve_models:有缓存直接回旧列表,过期渠道丢后台(_schedule_refresh)。两条纪律:① 某渠道一条缓存都没有时才同步等(冷启动 / 新渠道);② 后台刷新收敛在services.model_refresh_tasks,model_refreshing去重,lifespan关闭时cancel()+await。代价:列表最多滞后一个 TTL。 - 逐渠道增量 publish:每拉完一条渠道
publish_aliases()一次(就地更新,executor 闭包引用同一 dict),不等最慢那条(zen 逐个探活可占 12–15s,期间别名表为空会让_narrow_providers按全部渠道放行)。合并逻辑收敛到merged_entries()。
单模型查询 GET /v1/models/{model_id:path}:与列表共用 _revalidated_providers + _served_entries。命中三种写法(对外 id / 展示名 / 任一渠道 raw_ids),strip().lower() 比较。:path 容纳带命名空间的原代号(dots-studio/dots-3-note-preview:free)。白名单外与不存在同形 404(model_not_found),客户端无法探测池子。能力分与列表同源。
每次成功拉取后把未过滤原始表原子写进 DATA_DIR/model_catalog.json(tmp + os.replace,格式 {"version":1,"providers":{pid:{"saved_at":…,"models":[…]}}})。启动时 main._restore_model_list 同步读回并立即 publish 别名表(零上游请求),预热退化为后台刷新;saved_at 折进 model_list_fetched_at,故不会重拉刚恢复的表。
三条纪律:① 落盘/读回一律宽容(损坏、版本不符、字段缺失、记录非 dict 只丢该渠道,saved_at 超 7 天整条丢弃);② 存原始表不过滤(黑名单热更立即生效);③ 恢复只覆盖「已注册且当前有可用凭证」的渠道。启动预热不阻塞:lifespan 把预热丢后台(zen 最慢探活十几秒,内联会让 /health 不响应)。另有 MODEL_CATALOG_MINUTES 兜底刷新。
数据源 https://openrouter.ai/api/v1/models(OPENROUTER_MODELS_URL,匿名)。一次抓取同时得刊例价、明细元数据(architecture / top_provider / supported_parameters)与 Artificial Analysis 三项指数,供成本估算、模型列表页与 Playground 能力分。
- 后台任务
openrouter_catalog(OPENROUTER_CATALOG_MINUTES,默认 1440、下限 60、可热更)一轮建三张表:build_price_table/build_model_catalog/build_benchmark_table。空价表视为失败,不覆盖已有表。 - 能力分:
intelligence_index/coding_index/agentic_index,经 OpenRouter 公开接口分发(AA 官方 API 需 key)。字段类型意外时安静跳过。 - 浮动别名取同族最新快照的分(
_snapshot_info):OpenRouter 同时挂浮动别名与带日期快照,用canonical_slug声明同属一次发布,且只对同一快照发布一个评测变体(实测浮动别名Non-reasoning24.7、-1017Max39.5)。本项目默认开推理,与 Max 同口径,故按canonical_slug日期取同族最新快照;显式带日期戳的 id 仍用自己的分。家族只从带-YYYYMMDD的 canonical 派生(mistral-largevsmistral-large-2512是不同模型,不借分)。 - 失败安静降级:网络 / 结构坏 / 空表 / 快照损坏 / 版本不符 / 超龄一律退化为空表,条目不带
benchmarks字段(不是null),页面显示—。 - 落盘
data/openrouter_catalog.json(CATALOG_VERSION=2,三表同文件同saved_at,超MAX_AGE_SECONDS=7 天过期 → 全退空),启动同步回灌;无快照时后台补拉(_warm_model_catalog)。 - 挂
app.state.price_table/models_dev_catalog/model_benchmarks/price_saved_at。刷新是就地clear()+update(),不换引用。
匹配纪律(src/model_match.py,三处共用):match_keys(*texts) 把任意写法展开成候选键集合(归一键 + _ALIAS_RULES 显式等价 + 剥 _DROP_SUFFIXES + 版本号点/连字符互换),lookup 只做等值匹配,命中不唯一返回 None(glm-5.3 绝不匹配 glm-5.3-flash)。规则全部显式登记并附实测来源——渠道分发代号逐 id 登记(deepseek-v4.1-flash-0930 / -sg → deepseek-v4.1-flash),因为 OpenRouter 的 -1017 快照有自己的价位,通用剥日期会把两套价格混成一个。
展示范围(同一份表):/v1/models 每行可选 benchmarks;/api/playground/models 同上;/api/model-catalog 每行可选 benchmarks + available + 顶层 saved_at。available = 当前渠道真能提供(口径同 /v1/models),供默认关闭的「只看当前渠道」过滤。排序:sort=intelligence 按综合智能排(无分排末尾,不冒充 0 分),只给这一个分项入口。不做排序的地方标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」。已知取舍:family / open_weights / release_date 上游不提供(后端固定 null / false)。
来源:TRAE 的 expires_at 有值(= JWT exp);CB 恒为 0(OAuth 登录与刷新都不带),只能从 JWT exp 解析;CodeArts 用 expiration,签发时间借 refresh_token JWT 的 iat。实现集中在 provider/token_expiry.py(渠道中立,不 import provider 子模块,否则会把 db 层拖进来)。
credential_token_times(data)返回(签发, 到期)epoch。到期优先显式expires_at/expiresAt/expiration,缺失 / 非法时遍历 token 键解析 JWTexp;签发只来自iat。拿不到返回 0 = 未知,不猜本地 TTL。只借iat的兜底:CodeArts access token 是 STS AK/SK(非 JWT),但 STS 响应同时轮转出refresh_tokenJWT,其iat即签发时刻,故_ISSUED_ONLY_KEYS只借iat、不借exp(后者 30 天寿命会把 2h 凭证画成 30 天)。jwt_times/jwt_expiry只 base64url 解码不验签;异常一律 0。normalize_epoch()归一毫秒。
落库:token_expires_at(add() 与 save_credential_data() 都写回;JWT 派生值不写回凭证 JSON,否则刷新后旧值残留成「权威」)与 token_issued_at(仅诊断)。老库不批量回填:列表读到时按需从 data_enc 派生(_token_times_from_blob(),失败返回 (0,0)),写回后只读列值。读路径只下发绝对 epoch,不代前端算剩余秒数;前端 tokenExpiryView() 用同一时钟现算,阈值经 TOKEN_EXPIRY_WARNING_SECONDS 下发。短寿命凭证不标「即将到期」:整段寿命 ≤ 预警窗口 2 倍时(CodeArts STS 2h、窗口 1h)恒标红会淹掉真告警;真过期(剩余 ≤0)仍标红。未知显示 —,绝不当成已过期。接口:GET /api/credentials 响应带 token_expiry_warning_seconds,每条带 token_expires_at / token_issued_at。
做法:额度探测写回时比对余额,记一条流水(credit_events),写入放在 save_quota 同一事务(分两次读改写会与并发探测交错)。
- 账本由渠道决定(
quota_ledger):pool= 套餐/每日余额(CodeArts 之外),credits= 积分(CodeArts)。两本账量级与语义不同,混读会被当成同一本账。 - 归因纪律:diff 只能看到
[上次探测, 本次探测]的净变化(其间签到、成长、对话可能同时发生)。source只表达归因已知度:observed(净变化,delta 可正可负或 NULL)、sync(首次建基线,delta NULL)。前端文案一律「净变化」,不写「签到 +5」。 - 只记有信息量的行:首次记
sync;余额未变不记;变化记observed;任一端未知记(delta NULL);两端都未知不记;凭证被并发删除不记。window_start是上次探测时刻。 - 接口
GET /api/credentials/{id}/credit-events?limit=20&ledger=pool|credits(未知凭证 400,未知ledger回落pool)。保留期 90 天(purged_credit_events)。
- 口径:
overview/by_provider/by_model/by_user/timeline/model-timeline读小时汇总(usage_hourly,永久);只有events读明细(90 天)。代价:最近 ≤5 分钟未进汇总的不计入。凭证维度读明细表(小时汇总主键无credential_id),只覆盖 90 天。 - 双写:
record()写明细同时增量累加当前小时行;rollup_hourly(每 5 分钟)全量重算对账,两者幂等。 - TRAE credit 为推算值(
credit_estimated):上游token_usage只给 token,按官方公式折算(src/provider/trae/pricing.py),展示加≈。CB 是上游真值。与刊例不符的走pricing.MEASURED_EFFECTIVE_OVERRIDE。 - 成本(估算):
token × 公开刊例价,单价取自 OpenRouter(抓取时乘 1e6 转 USD/百万),按(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价折美元,再按写入时汇率(USD_CNY_RATE)折人民币。写入时定值,历史不重算。匹配不到就不计(cost_known=0→None、显示—),故成本是下限。 - 缓存命中率 = 命中 ÷ 输入 token(1 位小数),未上报或输入 0 显示
—。formatCacheRate唯一实现,三处共用。分组表那列是派生只读列(不给排序键)。 - 分组表合并列:
请求(数 · 成功率)、Token(输入/输出 · 命中率)、速度(正文/总产出)、平均延迟(耗时/首字)、P95 延迟,14 → 8 列。排序走SortMenuHead,子列键即GROUP_SORT_COLUMNS白名单键。延迟拆两列是因为均值读小时汇总、P95 读明细,口径与覆盖期不同。 - 明细合并列:
Token/延迟/速度/渠道 / 凭证,15 → 约 10 列。时间拆两行(HH:mm:ss在上、日期在下),请求 ID 提示挂该格。SortMenuHead让一个列头承载多个排序键。命中率走EVENT_SORT_COLUMNS["cache_rate"]:CASE WHEN e.input_tokens > 0 THEN CAST(e.cached_tokens AS REAL) / e.input_tokens ELSE NULL END;速度两列由_row_speed_sql("e.")生成,与events()的 SELECT 同一片段。 - 请求 ID:
X-Request-ID(webapp/request_id.py最外层)。入站合法([A-Za-z0-9._:-]{1,128})则沿用,否则uuid4().hex;同一中间件把会话标识头写进 contextvar 供粘性读取;ID 经request_context传进usage_events.request_id。不透传上游。 - 延迟均值只算成功请求(
SUM(latency_ms WHERE ok=1)/ok_count)。 - 分组表延迟四列(
avg_latency_ms/avg_ttfb_ms/p95_latency_ms/p95_ttfb_ms):均值读小时汇总,分位只能从明细算(usage_hourly只有latency_sum/ttfb_sum,还原不出分布)。_percentile_join()生成「按维度分组在usage_events上排名」的 LEFT JOIN 子查询,独立应用作用域与时间窗(列ts)。用 LEFT JOIN 而非 INNER(全失败组仍要出现)。排名用窗口函数ROW_NUMBER() OVER (PARTITION BY key ORDER BY (col IS NULL), col)+COUNT(col) OVER (...)(SQLite ≥3.25);nearest-rank 用整数(95 * n + 99) / 100。ttfb_ms为 NULL 时 latency 与 ttfb 各自独立排名。实测本地 5.7 万行按渠道算 P95 约 0.09s,加 24h 窗约 0.006s。 - 输出速度(
tps/tps_visible/tps_estimated):tokens ÷ 生成时间总和,不是逐条比值平均。两列同分母:tps_visible=MAX(output_tokens - reasoning_tokens, 0)之和,tps=output_tokens之和(思考也计入总产出行;思考占 50–67% 时两者约 190 与 63 tok/s)。主口径只算经路由的成功请求且output_tokens > 0,分母latency_ms - ttfb_ms,两道下限:窗口 <SPEED_MIN_GEN_MS(200ms)或单样本 >SPEED_MAX_TPS(1000 tok/s)剔除(实测 901 token / 3ms = 30 万 tok/s 是测量失效)。ttfb_ms为 NULL 不进主口径。某组无样本时回落端到端latency_ms并置tps_estimated=1(前端加≈)。两套聚合在_percentile_join()同一次扫描算完;overview()另在usage_events跑_speed_aggregates(ref="")。_speed_value()对每个分子单独判空(正文为 0 →None,显示—)。 - 逐请求速度:
events()每行带tps/tps_visible,由_row_speed_sql("e.")同时供 SELECT 与排序键。规则同源(_routed_guard)但不回落估算:无生成窗口(ttfb_msNULL、输出 < 100、窗口 < 200ms 或 > 1000 tok/s)给NULL;正文 0 用NULLIF(…, 0)给NULL,不拿 0 冒充速度。实时流不查库,StatsCollector._notify广播前调row_speed()(Python 版,同一判定)。 - 回填脚本:
scripts/backfill_trae_credit.py/backfill_cost.py/rollup_hourly.py,均先报缺口再--apply。 - 自定义时间窗:所有统计端点接受
since+until(epoch 秒),半开区间[since, until);可单边或都不给。取值列分流:小时汇总hour_utc、明细ts(_where里按since_col/until_col指定),与before(rowid 游标)分开。前端「自定义」两个datetime-local(src/lib/customRange.ts本地时区解析),起点不早于终点整窗作废;overview挂keepPreviousData。 - 钻取与导出:
events接受provider/model/credential_id(AND),分组表点行即钻取(用户维度走username)。?format=csv仅 admin/operator,固定rowid ASC,多取一条判截断(EXPORT_MAX_ROWS = 10000,超限置X-Export-Truncated: 1)。src/stats/csv.py纯函数:以= + - @(及制表符/回车)开头的文本单元格加前导单引号(数值原样);UTF-8 BOM;ISO 8601 UTC;\r\n。CSV 与页面共用_ROW_SPEED_VISIBLE/_ROW_SPEED_TOTAL,不可测行留空。 - 实时流(SSE)
GET /api/stats/events/stream:进程内广播而非轮询,StatsCollector.record在事务提交后调LiveEventHub.publish,事务回滚的行不会漏出。背压:每订阅者有界 deque(MAX_BUFFERED = 200),满了丢最旧;唤醒用asyncio.Event。并发上限MAX_SUBSCRIBERS = 8,超出 429 +Retry-After: 10。作用域与过滤在订阅者侧(_scope+ 三过滤,None不限制);since/until不参与。脱敏:payload 不含credential_name,仅 admin/operator 连接由端点逐条补。15s 注释帧保活;Cache-Control: no-store+X-Accel-Buffering: no。生成器finally注销订阅(漏注销会泄漏槽位)。单进程限制:多进程各推自己写入的行。
所有 GET 列表接受 sort(白名单键)+ order(asc/desc)。未知 sort 或非法 order 回落默认,不报错;sort 是白名单键而非 SQL 列名,用户输入永不进 SQL。契约在 src/sorting.py:parse_sort_order(唯一入口)、sql_order(tiebreak 稳定次序)、sort_rows(None 值无论升降序都排最后)。明细默认(time 降序)走 rowid 游标返回 next_before;按其它列 / 时间升序 / 显式 offset 时改 LIMIT/OFFSET 并返回 total。适用:/api/credentials、/api/users、/api/api-keys、/api/audit、/api/alerts、/api/model-catalog、/api/credentials/{id}/credit-events、/api/credentials/{id}/accounts、/api/stats/by-*、/api/stats/events。
扁平列表统一后端分页,前端共享 Pagination(PAGE_SIZE_OPTIONS = [20, 50, 100],DEFAULT_PAGE_SIZE = 50)。契约:GET 接受 limit + offset,响应在原列表键旁新增 total(筛选后条数)。仓储 limit=None 表示不分页。实现分流:users / api-keys / alerts / audit 走 SQL LIMIT/OFFSET + 独立 count;model-catalog 排序键含派生字段,先 sort_rows 再内存切片(_filter_catalog 支持 q 与 only_available,顶层 count 仍是目录全量)。游标分页的明细端点无总页数,文案退化为 第 N 页。分组类表不分页。
TaskRunner(runner.py)每类任务一个独立 asyncio 循环,失败只记日志;间隔有安全下限。Pacer 两种模式:后台任务严格模式(单锁串行化);聊天并发模式(allow_concurrent=True,按「渠道 + 凭证身份」分桶放行并发,只有同凭证顺序连发才补最小间隔)。stream_chat 的 finally 调 release(key)。抖动(JITTER_RATIO = 0.25):定值间隔(min == max)只向上抖 [low, low × 1.25]——low 是频率风控下限不可击穿;min < max 的区间本就均匀随机,不再叠加。抖动源是构造期 random.Random(0)(测试可复现)。
| 任务 | 周期 | 行为 |
|---|---|---|
| 额度探测(quota_probe) | 启动立即一轮(不节流)+ 每 QUOTA_PROBE_MINUTES(默认 60) |
写 credentials.quota_* / quota_expiry_ladder / quota_packages / health |
| token 预刷新(refresh) | 每 REFRESH_INTERVAL_MINUTES(默认 30,下限 5) |
到期前 REFRESH_SKEW_HOURS(默认 24h)窗口内轮换;短寿命渠道自行封顶(CodeArts 2700s,必须严格宽于本周期);UpstreamReloginRequired 时标记并硬禁用 |
| 每日签到(checkin) | 每 10 分钟 | 成功即封账(日期:scope → 截止时刻,内存态,跨天清理);渠道可回填 seal_until(Qoder 10:00 换窗口);失败重试 |
| 成长中心(growth) | 每 GROWTH_INTERVAL_MINUTES(默认 60,下限 5) |
仅 CB:7 类领取;落 growth_events + 回写 growth_last_result |
| 活跃上报(activity,默认关) | 每 10 分钟醒,仅 ACTIVITY_REPORT_HOUR(默认 10 点)窗口内执行 |
CB + 国际版:补发 chat_request_send;按「endpoint + userId」隔离、当日封账 |
| 明细清理(retention) | 每 5 分钟 | usage_events 全量重算小时汇总(幂等 upsert)+ 90 天清理;同期限回收 credit_events / audit_events / alert_events / growth_events。跑在 asyncio.to_thread |
| 数据库备份(backup) | 每 BACKUP_INTERVAL_MINUTES(默认 1440,下限 60) |
VACUUM INTO + integrity_check + 0600,保留 BACKUP_KEEP(默认 7)。跑在 asyncio.to_thread |
渠道模型列表刷新(注入协程 api/models.py::list_models) |
每 MODEL_CATALOG_MINUTES(默认 30,下限 5) |
兜底重拉(TTL 门禁 + 逐渠道 publish + 落盘)。周期跟 zen 判活缓存(30 分钟)对齐 |
| 模型目录刷新(注入协程) | 每 OPENROUTER_CATALOG_MINUTES(默认 1440,下限 60) |
拉 OpenRouter → 三张表 → 落盘 → 就地换入 |
| 运维告警(alert) | 每 ALERT_INTERVAL_MINUTES(默认 5,下限 1) |
评估四类风险,命中落 alert_events 并可推 webhook;同 (规则, 对象) 静默窗内只报一次 |
| 上游状态聚合(upstream_status) | 每 UPSTREAM_STATUS_MINUTES(默认 5,下限 1) |
并发抓官方状态(单源超时 UPSTREAM_STATUS_TIMEOUT_SECONDS)→ 归一 → 原子替换内存快照;单源失败只记该源 error,整轮永不抛。启动预热一轮 |
| 模型主动探活(model_probe) | 每 MODEL_PROBE_MINUTES(默认 0 关闭,下限 1) |
只探仍被 blocked 锁住的 (渠道, 模型):max_tokens=16 最小对话 → 三态判定 → 落 model_probes。不写 usage_events、不动冷却、不改健康度。manual != '' 跳过 |
两个刷新任务是「注入协程」而非 tasks 模块:tasks/ 不 import api/ / benchmarks(会形成反向依赖),故 TaskRunner 接 Callable[[], Awaitable[object]],由 main.lifespan 闭包注入;None 时不装配、管理台不展示。
并发保护:HTTP 出口与后台循环都调 list_models,故 _refresh_providers 持模块级 asyncio.Lock(粒度是整次列表刷新,跨渠道合并与别名 publish 需看到一致全集)。
签到 / 成长的同账号隔离键:checkin_scope(data) or f"credential|{credential_id}"(provider 身份未知时返回空串,共享空 scope 会让第二个账号被 seen 永久跳过)。
记录口径:_guarded 在任务返回 None 时不入账(sleep 到点但 due()=False 是 no-op,当成成功会让页面显示「签到刚跑过」而当天一次没签)。TaskRun 只在真实执行或抛异常时写入;计数 runs 同样只涨真实执行。运行态只在本进程内,重启归零。TASK_SPECS 是静态描述,与 TaskRunner.start() 的循环一一对应;周期与开关运行时现算。GET /api/tasks 返回带 server_time(前端用服务端时钟算「距今多久」)。
手动触发 POST /api/tasks/{key}/run(admin + CSRF)返回 {...运行态, ran, busy},未知 key 400。复用 _loop_specs();差别只有 manual=True 时 activity 绕过 due(),以及开关不绕(run_once 返回 None 记 ran=false)。并发:self._running: set[str] 占 key(asyncio 单线程下「查 + 加」原子),撞上直接跳过本轮;手动触发在 _run_guarded 之前先查,命中回 busy=true 不等结果。ran 口径是「真跑了一轮(含失败)」。
成长中心失败判定(三层):协议层非 2xx 抛 GrowthRejected;业务码非 0 / 缺 data / 非 JSON 抛 UpstreamProtocolViolation(HTTP 200 也可能失败)。编排层 4xx 业务规则(名额用完、未解锁、抽奖没次数、能量不足)记 IDLE;5xx 与协议违规记 FAILED;401/403 置 session_dead 并停止后续。结论:failed 且 gained=False 才算整体失败。接单失败三类:prerequisite not met: <task_code> 归并一条并标 reportable=True(记 IDLE);task does not require acceptance 正常应答;其余逐条 FAILED。任务契约五态:not_accepted → 接单、accepted/in_progress → 等完成、completed → 领奖、claimed → 跳过。接单 body 是复数数组 {"task_codes": [...]}。403 不总是登录失效:未解锁档位返回 403 +「连续登录天数不足」,必须先于 session 判定。不可逆动作由 GROWTH_IRREVERSIBLE_ACTIONS 总控,手动与定时读同一开关;开关只挡消耗不挡查询。清理切点必须对齐到小时边界(purge_expired):rollup_hourly 对整行是 REPLACE 语义,边界小时只删一半会让下一轮把汇总覆盖成剩下那一半,被删部分永久丢失;代价是明细最多多留 1 小时。告警规则判定是纯函数(tasks/alerting.evaluate_alerts),阈值统一「≤0 即关闭」。
| 规则 | 触发 | 信号来源 |
|---|---|---|
pool_empty(critical) |
total>0 且 ready < ALERT_POOL_READY_MIN |
CredentialRepository.pool_counts(与 /healthz 同口径) |
task_failed |
某任务连续失败 ≥ ALERT_TASK_FAILURES |
TaskStatusStore.failing(成功一轮清零) |
token_expiring |
token 剩余落在 (now, now+ALERT_TOKEN_EXPIRY_HOURS] |
CredentialRepository.expiring_tokens |
error_rate |
窗内失败占比 ≥ ALERT_ERROR_RATE_THRESHOLD 且样本 ≥ ALERT_ERROR_RATE_MIN_REQUESTS |
StatsCollector.window_error_rate |
pool_empty取total>0:一个凭证都没有是「没配」而非「耗尽」。task_failed只列真跑过且达阈值的 key。token_expiring:NULL 时从密文派生,派生不出视为未知;硬禁用排除;寿命本就短于窗口的排除(CodeArts STS 2h vs 默认窗口 24h)。error_rate读usage_events明细(告警窗是分钟级)。
投递:每条命中落 alert_events(落库而非只留内存),webhook 配置 ALERT_WEBHOOK_URL 时逐地址 POST(多个逗号分隔,全部成功才算 delivered),投递失败只记 delivery_error。静默去重:同 (rule, scope) 在 ALERT_SILENCE_MINUTES 内只落库 / 推送一次。AlertTask 与运行态共享同一个 TaskStatusStore(自建 store 会让「任务连续失败」永远读到 0)。
迟滞与恢复(ALERT_FOR_TICKS / ALERT_CLEAR_TICKS):「连续 for_ticks 轮命中」才告警(默认 1),「连续 clear_ticks 轮不命中」才确认恢复(默认 2)。恢复时给该 (rule, scope) 全部未恢复的行写 resolved_at 并外推 status="resolved" webhook;管理台把 resolved_at 非空显示为「已恢复」。迟滞计数进程内、重启归零;但「哪些仍未恢复」是已落库事实,AlertTask 构造时从 unresolved() 重建 _active(不重建则重启后恢复分支遍历空集合,历史行永远等不到 resolved_at)。重建只恢复集合与通知所需文案(PII 片段按 _PII_DETAIL_KEYS 反推),不恢复连击计数。
签名:webhook 恒带 X-Coding2API-Signature: sha256=<HMAC-SHA256(APP_SECRET, body)>,签名算在实际发送字节上(故发预序列化 content 而非 json=)。不设开关。
上游状态聚合(src/upstream_status.py + src/tasks/upstream_status.py):只做解释不做决策,不进选号 / 熔断。只存内存、不落盘(重启后拿几小时前的「一切正常」比显示「尚未抓取」更危险)。
- 声明式源清单
SOURCE_SPECS(key / 展示名 /api_url/page_url/kind/scope/related_providers),加源加一行。scope=channel与本站渠道一一对应(opencode→zen、kilo→kilo);scope=model靠related_providers标注,不做模型名模糊匹配。 - 四种解析器(纯函数,
ValueError表示顶层形状不可用):statuspage(/api/v2/summary.json,Kilo / Moonshot / MiniMax / OpenAI / Anthropic)、opencode(/api/status,逐模型 TTFT / tps,breakdown展开多行)、betterstack(/index.json,JSON:API)、atom(DeepSeek/history.atom,只有事故史)。 - 六档归一:
operational/degraded/partial/major/maintenance/unknown;Statuspagecritical→major。aggregate_level取最差,unknown权重 -1(一个源连不上不该压过真实故障);空输入unknown。 - 失败隔离:
fetch_source捕获一切 →SourceStatus(level=unknown, error=...);run_once全源失败也只返回不抛。页面用summary.abnormal(除 operational / unknown)与summary.unknown(抓取失败)分开展示。 - 事故归一:各源生命周期写法不同,统一成
Incident.ongoing布尔;先过滤再截断到MAX_INCIDENTS = 5。 - 启动预热:
_warm_upstream_status后台跑一轮,失败仅记日志;关闭时取消任务。
GET /v1/user/balance:DeepSeek 形状,Bearersk-...,供 Cherry Studio / cc-switch。余额来自额度探测缓存,按可用凭证汇总,单位上游 credits。从未探测成功时balance_known为false(未知,不是 0)。GET /health:{"status":"ok"},存活探针,不鉴权。GET /healthz:额外给池计数,ready=0时 503(就绪)。两者都不鉴权,都接受HEAD。
{
"status": "ok", "service": "coding2api", "version": "0.9.1",
"credentials": {"total": 5, "ready": 4, "cooling": 1, "paused": 0, "disabled": 0}
}五类计数互斥、合计 = total,判定优先级 disabled → paused → cooling → ready。口径复用调度器的 Candidate.is_selectable(CredentialRepository.pool_counts),不另写 SQL。
⚠️ /healthz的ready不看health:额度耗尽的号(health < 0)仍算ready。侧栏「凭证管理」角标是另一套口径(attention_counts:disabled + health < 0)——一个问「能不能打上游」,一个问「有没有待办」,不一致不是 bug。
一个聚合端点覆盖整个侧栏(避免 Layout 挂 7 个页面级 hook),60s 轮询。
- 按角色裁剪 payload,缺失的键 = 无权看(不是 0):admin-only 的
alerts/tasks/users不下发给 operator/viewer;operator 的credentials/upstream/upstream_level/model_catalog_stale不下发给 viewer;api_keys全员但只数本人。不做整体 403(viewer 也要看自己的 Key)。前端lib/navBadges.ts先判undefined再判值。 - 计数与页面数据不是同一口径:
alerts只数resolved_at IS NULL;tasks用last_ok is False(last_ok is None与已关闭不计);users数activation_digest IS NOT NULL(含已过期);api_keys数expires_at <= now + 7d(含已过期,NULL 不计)。 - 上游角标读
summary.abnormal(含 maintenance);upstream_level只用于定色调。 - 模型目录陈旧:
price_saved_at为空 →false;否则now - price_saved_at > 2 × OPENROUTER_CATALOG_MINUTES才算。 - 未装配回退:
task_runner/upstream_status未挂时回 0 /unknown而非 500。 - 前端防御:
count()显式校验typeof === "number"+ 有限正数,非数字静默不渲染。
src/api/metrics.py 零依赖手写 text 格式(不引 prometheus_client)。默认开、不鉴权,METRICS_ENABLED=false(可热更)时 404。路由注册在 SPA catch-all 之前。
| 指标 | 类型 | 标签 | 数据源 |
|---|---|---|---|
coding2api_requests_total |
counter | provider / model / status(ok / error) |
usage_hourly(error = requests − ok_count) |
coding2api_tokens_total |
counter | provider / type(input / output / reasoning / cached) |
usage_hourly |
coding2api_credentials |
gauge | state(ready / cooling / paused / disabled) |
pool_counts()(与 /healthz 同口径) |
coding2api_upstream_errors_total |
counter | provider / kind |
StatsCollector.error_totals()(进程内,重启归零) |
coding2api_task_failures_total |
counter | task |
TaskStatusStore.failures()(进程内) |
要点:① 禁高基数标签(不打 credential_id / api_key_id / username / request_id);② 前两项读永久小时汇总,计数跨重启单调;③ 进程内计数器随重启归零,Prometheus 按 counter reset 处理。文本转义只处理 \ / " / 换行。已知偏离:kind 用进程内计数(小时汇总无 error_type 维度,用明细会随裁剪下降)。
RequestLogMiddleware(纯 ASGI)每个业务请求结束写一行 logfmt:event=request request_id=… method=… route=… status=… duration_ms=…。补充而非替代 uvicorn access log(后者带 query string、无请求 ID、无法与 usage_events.request_id 对上)。
- 脱敏:只取
scope["path"],从不读query_string;不碰 body / 头;不写用户名(靠request_id关联)。路由用模板(scope["route"].path),未匹配回落清洗过的原始路径([A-Za-z0-9._~/%:@-]之外替换?、截断 120 字符)。 - 只记业务请求:
/api/*、/v1/*、/authorize;静态资源与/health、/healthz、/metrics不记。 - 级别:
5xx→ WARNING,其余 INFO。finally里发,status取不到按 500。 - 装配顺序:
RequestIdMiddleware内侧、BodySizeLimitMiddleware外侧。热更REQUEST_LOG_ENABLED。
LogBufferHandler 把本进程记录写进有界环形缓冲(容量现读 LOG_BUFFER_LINES,调小立即裁最旧)。出口只有两个 admin 端点:GET /api/logs?limit=(默认 200)与 GET /api/logs/stream(SSE)。LOG_BUFFER_LINES == 0 时两者 404 且 root handler 摘掉。
- 脱敏是硬前提:
redact_or_drop(line)命中即整行丢弃:独立敏感词(token/secret/authorization/cookie/password/passwd/api_key及apikey/api-key/ 复数,任意大小写)、键值形态(access_token=、X-Api-Key:、set-cookie:、client_secret =、JSON 键)、密钥形状(sk-…,前面不能是词字符故task-run不误伤)、裸凭证(Bearer <值>)。空行也丢。代价是「token 已刷新」这类正常行一起被吞——宁可漏一条,也不把凭证推进浏览器。 - 只收本进程:attach 到 root logger,不读文件、不 tail。
- SSE 同范式:先发注释帧,再回放快照转实时;回放与实时按
seq去重。15s 保活,订阅者上限 4,超限 429 +Retry-After: 10,响应带Cache-Control: no-store与X-Accel-Buffering: no。finally注销订阅。 - 行内容:
seq/ts/level/logger/message(已脱敏、截断 4000 字符)/request_id。前端只做筛选、搜索、跟随最新、暂停与清空,无写入入口。
管理端点失败原因必须是稳定的机器可读枚举,不能是异常类名。POST /api/credentials/{id}/probe 的失败响应:
{ "probed": false, "reason": "credential_rejected", "detail": "upstream http 401" }reason |
含义 | 用户该做什么 |
|---|---|---|
credential_rejected |
上游 401/403 拒绝凭证 | 重新登录 |
rate_limited |
上游 429 | 稍后重试 |
upstream_unavailable |
上游 5xx | 等上游恢复 |
upstream_rejected |
其他 4xx | 检查账号状态 |
upstream_response_invalid |
响应结构不符 | 可能官方接口变更 |
upstream_timeout |
请求超时 | 重试 |
network_unreachable |
连不上(DNS / 拒绝 / 建连超时 / TLS 中断) | 检查网络或代理 |
unknown_error |
未归类 | 查 detail |
客户端读体中途断开(ClientDisconnect)不是服务端错误:回 499 + client_disconnected,不落 500(以前会冒泡到 ServerErrorMiddleware,刷屏且污染错误统计)。
detail 仅供排查,界面不得当主提示。前端 probeFailureLabel() 翻中文并对未知值兜底。对外 400/503 文案只带受控标识,不回显上游正文。
目录:tests/ 按 src/ 子包镜像(api/ auth/ compat/ config/ db/ deploy/ engine/ provider/<渠道>/ scripts/ stats/ tasks/ webapp/),每目录一个 __init__.py。跨模块回归集中 tests/regression/;逐字节等价的常量/纯函数收敛 tests/helpers/;夹具按作用域放 conftest.py;私有替身放非收集模块 _support.py。真连上游豁免:默认全 mock;需真探活的用例标 @pytest.mark.real_upstream("zen" | "kilo" | "openrouter")(已注册,--strict-markers 生效)。
| 目标 | 覆盖 | 方式 |
|---|---|---|
| 调度器(到期/冷却/三态排序/轮换/pin) | 100% | 纯单元,注入假 provider |
| 鉴权(users/apikey/session/rbac) | 100% | 单元 + TestClient |
| SSE 解析、事件映射、OpenAI 适配、OAuth/回调 | 100% | 单元 + 真实样本 fixture(codebuddy / trae) |
| HTTP 客户端 | 100% | respx mock 状态码 + body → classify |
| 统计/查询 | 100% | sqlite 内存库集成 |
| 其余 | 100% | — |
全量 100%(行 + 分支)是硬门槛:--cov-fail-under=100,缺口一律补测试,不用 pragma 达标。例外(须注释说明不可达):src/ 的按构造不可达兜底(__main__、协议演进防御、前置 return 死分支);tests/ 的替身实现了宽于本用例的方法。平台差异分支不用 pragma,必须 monkeypatch 测全。测试不得依赖本地 .env:构造 Settings() 必须显式传值(Settings(_env_file=None, APP_SECRET=...)),验证时临时移走 .env 再跑全量。fixture 存 src/provider/fixtures/,断言两个方向:解析正确与不静默(畸形 → UpstreamProtocolViolation)。当前只有 codebuddy / trae 有落盘样本;zen / kilo / qoder / codearts 已用逐帧单测覆盖(含畸形帧),尚未补真实样本——不用合成样本冒充。
| 类别 | 例子 | 位置 | 变更方式 |
|---|---|---|---|
| 启动期不可变 | APP_SECRET / HOST / PORT / DATA_DIR / USERS_FILE / 上游端点白名单 / PROVIDER_PROXIES |
config.Settings(frozen) |
改 env + 重启;不进白名单 |
| 运行时可覆盖 | runtime_settings.HOT_SETTINGS(60 项) |
RuntimeSettings 覆盖层 |
管理台改,立即生效 |
启动期项拒绝热更的原因:它们决定进程如何启动或作用于连接池,运行期变更只会让「当前进程」与「磁盘配置」静默分叉。优先级:runtime_settings 表 > .env。覆盖行只有被改过的 key;「恢复默认」= 删行。读取:RuntimeSettings 对热更 key 返回覆盖值,其余 __getattr__ 委托给 Settings。覆盖值内存缓存,写入后 reload(),读取路径不查库。消费端 config.live(value) 把标量或零参 callable 统一成取值器;生产传 lambda: runtime.xxx。已接线点:Scheduler.expiry_window / secondary_expiry_window、ConversationAffinity.ttl_seconds、Pacer.min/max_seconds、Executor.default_model / max_auto_continues / force_disable_thinking / reasoning_effort_map、GrowthTask.allow_irreversible、TaskRunner 周期与活跃上报时点、上下文压缩四项、USD_CNY_RATE、anthropic_thinking_efforts。
校验:白名单外 key 拒绝;类型与范围写入前校验;跨字段组合(pacer_min ≤ pacer_max)用「这批写完之后」的对端值比较,失败整批不落库。坏行读取时跳过并记警告。接口:GET /api/settings 返回 snapshot()(key/env_name/label/description/kind/value/default/overridden/task/group)+ 覆盖计数 + groups;PUT /api/settings body {"values": {key: 标量 | null}},null 恢复默认,写操作记审计。网关卡组表即 GATEWAY_GROUPS。不变量:每项要么属于一个任务、要么落在一个网关卡组,两者互斥且都有归属(测试守住,否则前端漏渲染)。
SettingsPage 一排 tabs:每任务一个 tab(运行态 + 配置项),无任务归属的按网关卡组各占一个。没有配置项的分组不出 tab;归属对不上的落「其他」,绝不吞掉配置。保存按钮与「N 项待保存」常驻顶部,草稿跨 tab 保留。useTasks 以 refetchInterval: 30_000 刷新,/api/settings 不自动刷新。任务卡片「立即运行」,运行中 / 已关闭时禁用;结果文案三态——已跑一轮(带 last_report)、没排上(busy=true)、跑了但 no-op(ran=false)。把第三种说成「已运行」就是谎报。
PROVIDER_PROXIES(启动期,默认 "" 直连):渠道=代理URL;渠道2=代理URL2,渠道取 KNOWN_PROVIDERS,协议 http/https/socks5/socks5h。作用于该渠道全部出站请求(聊天、额度/模型、后台任务、OAuth)。严格解析:未知渠道 / 非法协议 / 缺 = / 空 URL 一律 ValueError,只忽略空段(「以为走了代理其实直连」是静默安全问题)。工厂 build_client(timeout, proxy) 统一构造 httpx.AsyncClient(..., trust_env=False, proxy=proxy)。为什么启动期:代理作用于连接池,运行中改需重建 6 个客户端 + 3 个 OAuth 流。
trust_env=False 是全项目纪律:httpx 默认会读宿主环境与 macOS 系统代理(scutil --proxy),本机开着 Clash 时状态页 / OpenRouter 目录这类公共只读出站会被交给该代理,握手失败即整批失败(实测「上游状态全部抓取失败」「模型目录全空」)。凡自建 httpx.AsyncClient 的公共出站点都必须显式关闭:upstream_status.fetch_all、benchmarks.fetch_openrouter_models、tasks/alerting 的 webhook(provider/proxy.build_client 已内建)。
- 前端:后端对
web/dist用FileResponse每次请求现读磁盘,重建后刷新浏览器即生效。 - 后端:
src/下一切只在进程启动时装载;进程管理器只在进程退出时拉起、不监听源码变化。
于是会出现 「新前端 + 旧后端」:页面能打开,但新端点全 404(webapp/static.py 的 catch-all 排除 /api 与 /v1),前端归一成通用失败,弹出与真实原因无关的兜底文案。
docker compose up -d --force-recreate # Docker / compose
sudo systemctl restart coding2api # systemd诊断顺序(先确认版本再查业务):
# 1) schema 版本已迁移(期望 29)且账号已导入
sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;"
# 2) 路由存在:期望 401(未登录),404 = 旧后端进程
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users结论:改 src/ 必须重启;改 web/ 只需重建。升级 schema 时重启同时完成迁移与引导(幂等)。
只写 stdout / stderr,不写文件、不自行轮转(轮转交给 Docker json-file / journald / logrotate,模板见 deploy/)。必须在 build_app 里配 root logger:uvicorn 默认 LOGGING_CONFIG 只配 uvicorn / uvicorn.access(propagate=false),root 默认 WARNING 且无 handler,会让 logging.getLogger(__name__) 的 INFO 静默丢失;生产路径不经过 run(),故配置挂 build_app(幂等)。级别 LOG_LEVEL(默认 INFO);审计日志是 INFO,调到 WARNING 会一并关掉。
三层引导:① 导入 users.txt;② ADMIN_USERNAMES 点名提权为 admin(只剩引导期语义);③ 仍无活跃 admin 则启动失败。require_admin 管用户管理、配置、审计、告警;require_operator 管凭证写与只读运维页;Principal.is_operator 是派生 property,统计口径同界。
会话吊销不建会话表:users.session_epoch 进 Cookie 的 ep 声明,改密 / 降级 / 启停 / 硬删一律 +1,principal_from_request 每请求比一次。角色不进 Cookie,每请求现读 DB(塞进已签名 Cookie 会让降级延迟生效)。老 Cookie 无 ep 按 0 处理。
一次性激活令牌:建号 / 重置写随机占位密码 + 32 字节 URL-safe 令牌,库里只存 SHA-256 摘要 + 过期时间(TTL 24h),明文只在响应里回显一次,前端拼 /activate?token=...。首登强制改密:must_change_password=1 时精确白名单放行(GET /api/auth/session、POST /api/auth/password、POST /api/auth/logout),其余 403 password_change_required。
审计 audit_events(id, ts, actor, action, target, detail, ip, ok),动作常量在 audit/actions.py。覆盖登录成功/失败、账号建/激活/改角色/启停/改密/重置/硬删、凭证导入/删除/启停/pin/复活/切换账号、告警人工恢复。绝不记密码或令牌明文。保留期同 usage_events。防锁死三层:Web 端 _guard_last_admin + SelfTargetError;CLI 删最后活跃 admin 拒绝;bootstrap 无活跃 admin 启动失败。硬删只在 scripts/create_user.py --delete --force(usage_events.username 是裸文本无外键,硬删会留孤儿统计,故 UI 不暴露 DELETE)。
- 同步 sqlite3 而非 aiosqlite:本地微秒级操作,asyncio 封装开销大于收益。
- 双 httpx 客户端:聊天流
read=None防长流截断;短请求总超时 30s 防悬挂;共享trust_env=False。非流式由引擎聚合同一流式上游,悬挂兜底是UPSTREAM_COMPLETE_TIMEOUT_SECONDS(默认 600s,超时按瞬态错误换号)与事件条数上限(complete_max_events)。 - 手写 SQL 而非 ORM:11 张表规模下 ORM 收益为负。
- polling OAuth 不转回调:上游协议决定;TRAE 回调走主端口 +
PUBLIC_BASE_URL。/authorize无鉴权,防滥用三条:无进行中登录一律拒绝;pending 600s TTL;回调必须带匹配的loginTraceID。刻意不用 Origin/Referer 判同源(TRAE 回调本就跨源 302,且no-referrer可绕过)。app.state只保留 pending 的 state。 - Responses / Anthropic 出口只做各自客户端子集:不做
store=true/previous_response_id;include=["reasoning.encrypted_content"]接受并忽略;不发[DONE]。未经真实 Codex CLI 端到端验证。 - effort 映射默认不做,但缺省补值 + 可选机制:
setdefault补medium,只补缺省不覆盖显式值;内置默认矩阵不做,改为默认关闭的三项机制(§3.11)。 - Zen / Kilo 用虚拟凭证行而非特判:调度 / 冷却 / 统计 / 粘性全按
credentials行工作,种一条空凭证比在每个环节加「无凭证 provider」分支代价小。probe_quota恒probe_failed=True。种子只在默认装配路径且幂等;删除后重启复活是刻意的。 - Zen 注入
bash/read是「对上游撒谎、对客户端诚实」:只补缺;回包按index跟踪分片,有同义工具就改写透传,没有才过滤并收敛finish_reason。 - 内容风控指纹清洗只改出站副本:客户端历史不受影响。
- 统计一律以
usage_hourly为准:代价是最近 ≤5 分钟未进汇总的不计入。 - 成本是下限:匹配不到或未上报输入 token 的不计入(
null显示—,不拿 0 冒充免费)。 - 应用日志只写 stderr:自己写文件会与平台轮转争抢,容器里还会写进镜像层。
- 批量运维只收「已有单条端点」的动作:
probe/toggle/checkin/revive。refresh无单条入口且强刷会白耗一次性票据;delete破坏性太强需二次确认。批量与单条共用同一实现(probe_credential_once/checkin_credential_once)。 - 批量
toggle传显式enabled而非各自取反:混选时取反会让一半号走向相反方向。 - 批量审计只记一条汇总(
toggle/revive):50 个号是一次操作、一个意图。probe/checkin只读、不写审计。 - 批量
probe/checkin用信号量限制在途(BATCH_CONCURRENCY=4),不复用后台 Pacer:完全串行会让 50 个号等几分钟。toggle/revive是本地 DB 写,不受限。 - 窄屏表格卡片化改样式不改 DOM:
<768px一行 = 一张卡;不做「按视口渲染两套 JSX」(页面测试按 testid 断言,DOM 一变语义就没了)。字段名走TableCell label→data-label+ CSS::before(不新增节点)。display:block会让 Chromium/WebKit 丢隐式表格语义,故卡片模式打开时由TableCardsContext补显式role;不开卡片模式的表格不加任何role。断点与 Tailwindmd对齐(767.98px)。 - 一个渠道一个组:
web/src/lib/credentialList.ts的groupByProvider()按真实provider聚合。组上ledger由providerLedger()派生:codearts→credits,其余 →pool;组头额度合计标签因此对 CodeArts 写「积分合计」。行内额度、口径悬浮(quotaSemantics)与变动流水入口都按ledger取;credentialState()只认主账health。为什么后端不真拆:refresh_token一次性且与client_id+ DPoP 私钥绑定,两条凭证行会各刷一次、第二条必报the refresh token has been used并烧掉票据。