OmniRoute 弹性(Resilience)体系全解:Provider 熔断、连接冷却与模型锁定三层降级架构
OmniRoute 是一个统一转发 300+ 上游模型服务(含大量免费额度)的 AI Gateway。本文围绕其容错与高可用核心——**弹性机制(Resilience)**展开,系统讲解 Provider 熔断器、连接冷却(Connection Cooldown)、模型锁定(Model Lockout)等七层防护的设计目标、状态机、配置参数、底层实现与调试排障路径。读完你将能够:区分每一层机制的失效范围、精准调参(熔断阈值、冷却时长、退避策略、队列深度),读懂熔断器与连接状态的自愈原理,并能结合源码与测试自主排查“某供应商/某 key/某模型被跳过”的实际问题。本文主体依据仓库内 docs/architecture/RESILIENCE_GUIDE.md 整理,并对关键实现给出源码级佐证。
图源:该流程图源文件见 docs/diagrams/resilience-3layers.mmd。三层的请求路径为:请求先经过 Provider 层熔断检查(CLOSED/OPEN/HALF_OPEN),再经过连接冷却检查(该账号/key 是否可用),最后经过模型锁定检查(该 provider×connection×model 组合是否被锁定),通过后才真正执行上游请求。
一、设计总览:三层机制各管一段,切勿混为一谈
OmniRoute 拥有三套相互独立但彼此相关的弹性机制,每一套的**作用域(Scope)和目的(Purpose)**都不同。调试路由行为时,务必先把“这到底是哪一层的故障”区分清楚:
| 层级 | 作用域 | 目的 | 典型触发 |
|---|---|---|---|
| 1. Provider Circuit Breaker | 整个 provider(如 glm、openai、anthropic) |
上游/服务级持续失败时,停止向该 provider 发送流量 | 上游持续 5xx |
| 2. Connection Cooldown | 单个 provider 连接/账号/key | 跳过某一把坏 key,同 provider 的其他连接继续提供服务 | 账号级 401/403/429 |
| 3. Model Lockout | provider + connection + model 三元组 | 仅某一模型不可用或额度受限时,不整体禁用一个连接 | 模型级 429/404、Grok 模式权限失败 |
层级之间的典型交互如右:408/500/502/503/504 会熔断整个 Provider;账号级 401/403 只启动连接冷却;而 429(模型额度)走模型锁定。
二、Provider 熔断器:整供应商级短路
作用域:整个 provider(如 glm、openai、anthropic)。
目的:当某 provider 在上游/服务级别反复失败时,停止向其投放流量,避免把本可快速降级的请求全部打到一台持续出错的供应商上。
2.1 实现与状态机
- 核心类:src/shared/utils/circuitBreaker.ts
- 接入点:
src/sse/handlers/chatHelpers.ts、src/sse/handlers/chat.ts - 状态查询 API:
GET /api/monitoring/health - 手动复位 API:
POST /api/resilience/reset - 上游包装层:
open-sse/services/accountFallback.ts - 持久化表:
domain_circuit_breakers
熔断器共四种状态:
| 状态 | 行为 |
|---|---|
CLOSED |
正常放行流量 |
DEGRADED |
仍放行,但已进入“失败率抬升”的预警观察,日志记录告警 |
OPEN |
暂时屏蔽该 provider,combo 路由会跳过它 |
HALF_OPEN |
复位超时已到,允许少量探测请求(halfOpenRequests,默认 1)验证是否恢复 |
从源码看,src/shared/utils/circuitBreaker.ts 中的 CircuitBreaker 类还实现了按失败类型差异化阈值(kindThresholds,例如某类错误可 immediateOpen 直接断开而跳过 DEGRADED)、open→half_open→open 循环次数的指数退避(_effectiveResetTimeout() 依据 openCycleCount 将 resetTimeout 乘上 2^(cycle-esc) 幂次放大,上限为 resetTimeout * maxBackoffMultiplier)、以及状态机持久化(_restoreFromDb()/_persistToDb(),重启后熔断状态可从 DB 恢复)。同时它提供了 isLocalStreamLifecycleError() 与 isLocalExecutionError() 两个过滤器,用于把“本地流生命周期错误”(如 Codex WebSocket→SSE 桥接中 Invalid state: Controller is already closed、客户端主动断开 AbortError/request_signal_aborted)与“本地主机执行错误”(ENOENT/EACCES/EPIPE 等)排除在 Provider 熔断的失败计数之外——这些错误的根因在 OmniRoute 自身或调用方,而非上游,数它们会导致单个用户动作误伤整条 provider。
2.2 可配置默认值(Provider Profiles)
默认值定义于 open-sse/config/constants.ts 的 PROVIDER_PROFILES,并可在 Dashboard → Settings → Resilience 页面对三类凭据分别覆盖:
| 凭据类别 | 进入 DEGRADED(degradationThreshold) | 打开熔断(failureThreshold) | 复位超时(resetTimeout) |
|---|---|---|---|
| OAuth | 5 次失败 | 8 次失败 | 60s |
| API-key | 7 次失败 | 12 次失败 | 30s |
| Local(本地推理) | 由阈值推导 | 2 次失败 | 15s |
对应源码字段:
degradationThreshold:进入DEGRADED的失败次数门限(类内默认推导为Math.ceil(failureThreshold * 60%),但PROVIDER_PROFILES显式覆盖);failureThreshold:达到该次数即打开熔断并跳过;- OAuth 与 API-key 各自由环境变量
OMNIROUTE_CIRCUIT_BREAKER_OAUTH/API_KEY_THRESHOLD、…_RESET_MS、OMNIROUTE_PROVIDER_BREAKER_OAUTH/API_KEY_DEGRADATION_THRESHOLD等兜底。
注意:Local provider 的配置档案尚未暴露到 Resilience 设置页(源码中 local 段注释明确“Not yet wired into getProviderProfile()”)。
2.3 触发码(Trip Codes)与惰性恢复
- 熔断器只对 provider 级状态码
[408, 500, 502, 503, 504]计数; - 不要因账号级错误(绝大多数
401/403/429)触发熔断——那些应归入连接冷却(cooldown)或锁定(lockout)机制。
熔断器采用惰性恢复(Lazy recovery):OPEN 到期后不需要后台定时器推进,任何一次读取路径(getStatus()、canExecute()、getRetryAfterMs())都会先调用 _refreshOpenState(),若已超过有效复位时间则状态推进到 HALF_OPEN 并放行探测请求。这也解释了调试指南里“状态迟迟不自愈”的排查方向(见下文)。
三、连接冷却:坏 key 单独隔离,兄弟 key 不受牵连
作用域:单个 provider 连接/账号/key。 目的:坏掉一把 key 时只跳过它,同一 provider 的其它连接继续正常服务。
3.1 实现与关键字段
- 标记不可用:
src/sse/services/auth.ts::markAccountUnavailable() - 凭据选择:同一文件中的
getProviderCredentials* - 冷却计算:
open-sse/services/accountFallback.ts::checkFallbackError() - 设置项:src/lib/resilience/settings.ts
每个连接(connection)上维护的冷却字段:
| 字段 | 含义 |
|---|---|
rateLimitedUntil |
冷却过期的时间戳 |
testStatus: "unavailable" |
连接处于不可用测试状态 |
lastError / lastErrorType / errorCode |
最近一次错误的细节 |
backoffLevel |
指数退避计数器 |
3.2 默认冷却时长与退避
- OAuth 基准:5s
- API-key 基准:3s
- API-key 的 429:优先采用上游
Retry-After/ reset 响应头 / 可解析的 reset 文本(PROVIDER_PROFILES.apikey.rateLimitCooldown = 0即“0=遵循上游重试头”);OAuth 在无重试头时则用 60s 兜底 - 退避公式:
baseCooldownMs * 2 ** failureIndex(源码中即backoffLevel/failureIndex对应的指数退避)
同时有防惊群(anti-thundering-herd)守卫:避免并发失败同时把冷却时间过度拉长或对 backoffLevel 重复自增。
3.3 终态(Terminal states)≠ 冷却
以下三种是需要人工或改凭据才能恢复的终态,绝不能被瞬态冷却覆盖:
banned:由 banned-keyword / 账号封禁检测写入(机制详见 docs/security/BAN_DETECTION.md);expired:有界重试(EXPIRED_RETRY_MAX = 3,指数退避)后才转为终态,使瞬时 OAuth 错误在账号被永久停用前有机会自愈;credits_exhausted:额度耗尽。
这些终态会一直保持到凭据变更或运维手动复位。恢复路径同样是惰性的:rateLimitedUntil 过期后连接重新进入候选;一旦成功使用,clearAccountError() 会清空全部错误字段。
3.4 Session Affinity(#7274):会话亲和钉扎
作用域:单条客户端会话(X-Session-Id / x-codex-session-id / x-omniroute-session 请求头)钉扎到某一个连接,适用于任何 provider。
目的:让多轮 agent(Claude Code、aider、自定义 agent)跨请求保持在同一个账号上,减少跨账号上下文丢失,并规避那些“按账号维护会话状态”的 provider 在切换账号时反复出现的冷启动 429。
实现:
- TTL 解析:
src/sse/services/sessionAffinityPin.ts::resolveSessionAffinityTtlMs() - Pin 选择/创建:
src/sse/services/sessionAffinityPin.ts::selectSessionAffinityConnection() - 请求头提取(泛化到任意 provider):
src/sse/services/auth.ts::extractSessionAffinityKey() - 持久化钉扎表:
sessionAccountAffinity(见 src/lib/db/sessionAccountAffinity.ts) - 设置项:
sessionAffinityTtlMs(全局 TTL,单位毫秒,0表示关闭)
sessionAffinityTtlMs 原名是仅针对 Codex 的 codexSessionAffinityTtlMs,迁移脚本 124_generic_session_affinity_ttl.sql 将其泛化并把历史配置的 Codex TTL 带入新默认值。在 #7274 之前,resolveSessionAffinityTtlMs() 对所有非 codex provider 直接硬返回 0,导致 TTL 设置与三个会话头在其它 provider 上形同虚设——尽管钉扎机制和头部提取早已是 provider 无关的;修复移除了该 early-return,TTL 一旦全局设为大于 0,就对每个 provider 统一生效。
三个会话亲和头永远不会被透传上游:executor 在构建上游请求头时是从零构建,而不是透传客户端请求头,因此它只是内部关联 ID。
3.5 专属托管会话连接租约(Exclusive Managed Session Leases)
作用域:一个活跃的托管 HTTP 客户端/会话独占一个合格(eligible)的 OmniRoute 连接。
目的:为“需要在跨请求间建立硬性路由围栏”的客户端提供持久、独占的连接所有权。它不同于 session affinity 这种“软连续性偏好”:专属租约把生命周期状态持久化在 SQLite、强制“全局唯一活动 owner / 唯一活动连接”、并在派发前拒绝过期代数(stale generation)。
关键契约:
- 该特性按 API key 显式启用:托管 key 必须带有
lease:exclusivescope 与一个非空allowedConnections列表; - 生命周期端点为
POST /api/v1/session-leases,JSON action 为acquire、renew、release; - 托管推理请求需要携带不透明的
X-OmniRoute-Lease-Owner值与精确的X-OmniRoute-Lease-Generation; - owner 值形如
vlo_后接 43 个 base64url 字符,DB 只存其 SHA-256 哈希; - 每次最终派发围栏还会绑定已认证的 API key ID 与活动连接 ID;租约控制头会从日志、请求快照与上游 executor 头中移除;
- 租约拥有的是“连接”而非“模型”,因此切换模型不会解除绑定;普通模型/额度/健康/冷却/白名单规则依然权威,可能把同一代数迁移到另一条空闲连接;
- 若普通路由存在可用的托管候选,但每条空闲候选都被外部活动租约占用,则返回 HTTP
429(lease-capacity-unavailable 码、waiting-for-capacity 状态、以及基于最早相关过期时间推导出的有界Retry-After)。
三种相近机制的边界(勿混):
- OAuth 会话占用 = 进程内 OAuth 账号的软分布;
- 账号信号量 = 授予请求并发许可,请求结束即释放;
- 专属托管会话租约 = 带代数围栏的持久生命周期所有权。
四、模型锁定:单模型故障不拖垮整条连接
作用域:provider + connection + model 三元组。 目的:仅当某个模型不可用或额度受限时精准跳过该模型,而不是停用整条连接。
典型场景:
- 按模型计量额度的 provider 返回 429;
- 本地 provider 对不存在的模型返回 404;
- provider 特有模式/模型权限失败(如 Grok 的 modes)。
实现:open-sse/services/accountFallback.ts 中的 lockModel()、clearModelLock()、getAllModelLockouts()、recordModelLockoutFailure()、decayModelFailureCount()。
4.1 Model Cooldowns 面板(v3.8.0)
UI 位于 Dashboard → Settings → Model Cooldowns(组件实现在 src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx/dashboard/runtime/components/ModelCooldownsCard.tsx)),用于列出当前活动锁定:provider、connection、model、reason、expiresAt。运维可直接在卡片上手动重新启用某个模型。
REST API:
GET /api/resilience/model-cooldowns— 列出活动锁定;DELETE /api/resilience/model-cooldowns— 手动重新启用,Body 为{provider, connection, model},需 management 权限。
4.2 锁定参数设置卡 + 成功衰减恢复(v3.8.23)
模型锁定从“始终开启的硬编码行为”演进为完全可配置、默认关闭、自带自愈恢复路径的 opt-in 特性。
设置卡:Dashboard → Settings → Model Lockout(组件见 src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx/dashboard/settings/components/ModelLockoutCard.tsx)),与上面只读的 Model Cooldowns 卡不同——后者只展示,前者配置参数。默认值定义在 src/lib/resilience/modelLockoutSettings.ts 的 DEFAULT_MODEL_LOCKOUT_SETTINGS:
| 设置项 | 默认值 | 含义 |
|---|---|---|
enabled |
false |
总开关——默认关闭 |
errorCodes |
[403, 404, 429, 502, 503, 504] |
计为“模型级失败”的上游状态码 |
baseCooldownMs |
120_000(120s) |
首次失败的初始锁定时长 |
maxCooldownMs |
1_800_000(30min) |
升级后冷却的上限 |
maxBackoffSteps |
10 |
最大指数退避升级步数 |
useExponentialBackoff |
true |
重复失败是否按指数升级冷却 |
设置经常规 settings store 持久化,并通过 resilience 设置 schema 校验;卡片会对 baseCooldownMs/maxCooldownMs(要求 maxCooldownMs ≥ baseCooldownMs)与 maxBackoffSteps 做钳制。resolveModelLockoutSettings() 还会把错误码数组过滤到 100–599 的合法状态码区间。
成功衰减恢复(Success-decay):恢复并非纯靠计时器到期。一次健康响应会把该模型的失败计数“往回走”,使窗口中期已恢复的模型在定时器到点前就停止升级(甚至清零)。在成功命中 combo 目标时,open-sse/services/combo.ts 调用 decayModelFailureCount(),把存储的 failureCount 减半(Math.floor(failureCount / 2));减到 0 时整条锁定记录被删除。反向操作 recordModelLockoutFailure() 在升级窗口内的失败上自增计数并升级冷却。成功衰减与定时器到期两条路径都能重新启用模型。
状态存储:锁定状态保存在内存中(按 provider:connectionId:model 为 key 的进程内 Map),不落库——重启即丢失。设置持久化,而活动锁定状态是易失的。
五、Quota-Share 并发控制(v3.8.36)
订阅账号(GLM、MiniMax 等)通常只接受约 1–3 路并发,超出即触发 429 与冷却。这在 quota-share(qtSd/…)combo 下尤为尖锐——多个 API key 共享同一个上游账号。为此设计了三层防护避免共享账号被并发打爆。
5.1 每连接并发上限(max_concurrent)
每个 provider 连接都可声明 max_concurrent 上限(provider_connections.max_concurrent,在连接弹窗/API/DB 中设置),留空表示不限制。它是下方串行化层级的唯一旋钮——按账号真实并发数设置即可(例如 GLM 约 1、MiniMax 约 2)。
5.2 Quota-share 请求串行化
当 quota-share 派发命中一个声明了正数 max_concurrent 的连接时,并发请求会经由每连接信号量(key qsconn:<connectionId>)串行化:超出并发数的请求在队列中等待而非直接灌进账号。它是 fail-open 的——队列饱和或超时时会不带槽位地继续执行,绝不拒绝一个本可派发的请求。开关在 Settings → Resilience → Quota-share per-connection concurrency(resilienceSettings.quotaShareConcurrencyLimit.enabled,默认开启;见 src/lib/resilience/settings.ts)。未设置 max_concurrent 时行为不变。
quota-share 路由门控(
selectQuotaShareTarget,DRR + P2C)本身也是 fail-open 的,只会把已达上限的连接降权——在单连接池场景无法硬限制,因此真正兜住洪峰的是上述信号量。
5.3 Combo 冷却感知重试
对所有 combo 策略(开启时):若某次请求将结晶出一个短瞬态冷却的 429,则等待该冷却窗口并重新派发,而不是直接把 429 返回给客户端——用于覆盖 Gemini 级别 TPM/RPM 窗口(约 60s retry-after)下的多模型 combo,例如双模型 combo 的两个目标同时命中各自的模型级限速。重试窗口由 comboCooldownWait(enabled、maxWaitMs、maxAttempts、budgetMs)约束,在 Settings → Resilience 中配置。它绝不会等待 quota_exhausted(锁定到午夜)或 auth/not-found 类原因。
六、请求队列准入控制(v3.8.49 · issue #6593)
作用域:本地“每 provider+连接”限速队列(open-sse/services/rateLimitManager.ts,底层由 Bottleneck 实现),位于前面三层机制之下的一层。
maxWaitMs 是“执行过期”的遗留持久化名称。 resilienceSettings.requestQueue.maxWaitMs 会作为 Bottleneck 作业的 expiration 传入,而该计时器仅在派发后才启动,因此它约束的是“限速器托管的执行时长”,而不是在本地队列中的排队时长。过期时暴露为可信的本地 code: "RATE_LIMIT_EXECUTION_TIMEOUT"(HTTP 504);旧的 queue-timeout 码名仅为了可信内部后向兼容而保留。默认 15000ms,可用 RATE_LIMIT_MAX_WAIT_MS(环境变量)或 Dashboard(Settings → Resilience,UI 上限 1–30000ms)覆盖。队列驻留本身没有时间截止,请用下面的 maxQueueDepth 约束排队中的调用方。
maxQueueDepth — opt-in 准入上限(新增)。 resilienceSettings.requestQueue.maxQueueDepth 限制同一 provider+connection 同一时刻排队未派发的请求数。当队列已满时,新请求在到达 limiter.schedule() 之前就被以带类型的 code: "RATE_LIMIT_QUEUE_FULL" 错误快速拒绝——该拒绝非常廉价,并且发生在该请求后续任何 prompt 压缩/翻译等工作之前。默认 0 = 关闭,保持既有的无界队列行为;可配范围 0–100000。可用 RATE_LIMIT_MAX_QUEUE_DEPTH(环境变量)或 resilienceSettings.requestQueue.maxQueueDepth(Dashboard/API patch)覆盖。
准入检查本身是一个纯函数(open-sse/services/rateLimitManager/admission.ts::checkQueueAdmission),因此可以在不创建真实 Bottleneck 限速器的前提下做单元测试。
打开 #6593 的 RFC 还提出了
bypassCompressionOnRateLimit标志。本仓库open-sse/services/compression/管线是出站 LLM 请求的 prompt/上下文压缩(见chatCore.ts中resolveCompressionSettings/selectCompressionStrategy附近),而不是合成 429 响应体上的 HTTP 响应压缩——不存在可供字面 bypass 标志匹配的代码路径;并且该 prompt 压缩步骤目前在请求管线中位于withRateLimit()之前,要为“队列满拒绝”而重排跳过它是比本 issue 更大的独立改动,故有意未实现,留作后续(若 CPU 节省值得承担重排风险)。
七、慢流吞吐看门狗(#9709)
可选的 resilienceSettings.streamRecovery.throughputWatchdog 守卫用于检测“上游仍在发块、但助手输出低于配置的有用输出速率”的情况。它刻意区别于空闲超时:心跳与元数据不会重置任何一个计时器,也不算作进度;它也区别于硬性尝试截止时间(#9153),后者无论如何都是绝对安全上限。
工作方式:
- 需要经过预热期再加上一个完整滚动窗口后才允许中止;
- 统计 Chat Completions 与 Responses API 输出事件中的文本 delta(一个保守的 UTF-8 字节代理),忽略纯 usage 与空事件;
- 在 tool-call / reasoning 事件进行中时挂起判定;
- 默认关闭,可用
STREAM_THROUGHPUT_WATCHDOG_ENABLED=true开启;窗口、预热、最低速率与最小可测输出量均由常规 resilience-settings 归一化层约束(默认值源自open-sse/config/constants.ts的STREAM_THROUGHPUT_WATCHDOG,归一化逻辑见 src/lib/resilience/settings.ts)。
开启后,看门狗中止只作用于当前活动上游尝试:在任何客户端可见字节之前,既有的同账号早期恢复路径可能重开该尝试;提交(commit)之后,流绝不会被盲目重放,只有既有的安全“流中续传契约”能拼接后缀。收尾保持单次执行,因此用量记账与信号量释放不会被重复。
八、上游状态改写(纠正误报的配额错误)
作用域:某个把“临时配额耗尽”用错误 HTTP 状态上报的上游网关。 目的:在分类之前纠正误导性的状态码,让下游消费者(fallback 引擎、combo 聚合、客户端响应)看到该失败的“真可重试”本质。
8.1 问题背景
部分网关用不可重试的 HTTP 状态来报告临时配额耗尽。例如 agentrouter.org 返回 403(偶尔 400)并带中文响应体(用户额度不足 / 额度不足),而不是标准的 429。像 Claude Code 这样的客户端会把 403 视为永久性错误并中止会话;若不纠正,fallback 引擎也会把它分类为 AUTH_ERROR 而非配额事件。
8.2 实现
- 注册表 + 匹配器:open-sse/config/upstreamStatusRestatement.ts,提供按 provider 组织的规则列表(
{id, fromStatuses, toStatus, textMarkers, excludeMarkers, defaultRetryAfterMs}),经applyStatusRestatement()匹配; - 调用点:
open-sse/handlers/chatCore.ts的providerFailure:块,恰好在parseUpstreamError()解析出带错误 HTTP 状态的响应之后、任何分类运行之前,保证每个下游消费者看到的是修正后的状态码。内嵌在200SSE 流中的错误走的是另一条更靠后的流解析路径,暂不在本钩子覆盖范围——这是一个已知限制(agentrouter 的错误是作为错误 HTTP 状态浮出的,尚未触发该场景); - 重试资格:
429位于RETRY_AFTER_ELIGIBLE_STATUSES(open-sse/services/combo/unavailableRetryGate.ts)中,因此被改写的错误会携带真实的Retry-After窗口,而不是像死403那样裸露; - 合成
60sdefaultRetryAfterMs的语义:它只是“改写后的响应告诉客户端”的窗口,并非连接内部冷却/锁定时长——后者由实际处理该改写错误的机制单独管辖(连接冷却的退避升级,见 §3,API-key 基准3s;或模型锁定,见 §4,用于 agentrouter 这类按模型计配额的 provider)。路由器在内部重新合格的时间可能早于它对外宣告的 60s——这是刻意留下的余量,不是 bug。
永久性错误(agentrouter 的 无权访问模型)绝不被改写:excludeMarkers 即使在 textMarkers 命中时也能否决规则,错误保持原始状态码,不会无限重试。
8.3 匹配的分类规则与锁作用域
对应的 provider 分类规则(agentrouter-model-access-denied,见 open-sse/config/providerErrorRules.ts:reason: "auth_error"、scope: "model"、声明 6h 基础冷却)由 checkFallbackError(open-sse/services/accountFallback.ts)在通用 apikey 类 FORBIDDEN 早退之前咨询,并用 honorsRuleLockScope(provider) 门控(#10334——当前仅 agentrouter 在 HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist 内)。规则声明的 6h 冷却会以 fallbackResult.baseCooldownMs 流动,但仍被钳制到运维的 mlSettings.maxCooldownMs(默认 1_800_000ms/30min),与其他模型锁定一致;持久化的锁定 reason 仍是原先硬编码的 "forbidden",而非规则的 "auth_error"——端到端只采纳冷却时长,不采纳 reason 字符串。连接本身保持活跃,兄弟模型不受影响。
被改写的配额错误(额度不足)在生产中命中 agentrouter-user-quota-exhausted(reason: "quota_exhausted"、scope: "connection"、无自有声明冷却——持久化层的缩放退避默认生效)。自 #10334 起,ProviderErrorRuleMatch 上的 scope 端到端被消费,但仅限 HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist 内的 provider(今天只有 "agentrouter",经 honorsRuleLockScope() 门控)。对其它所有 provider,scope 仍只是信息性标注。checkFallbackError 把命中规则的 scope 作为 fallbackResult.ruleScope 浮出;src/sse/services/auth.ts 的 isAgentrouterConnectionQuotaScope() 是共享守卫,用于确认 ruleScope 确实可以安全地作为“连接级、可自愈信号”被采纳(scope "connection"、reason quota_exhausted、绝不 permanent、绝不 creditsExhausted)。两个消费方:
- 持久化(
markAccountUnavailable()):不再落入 passthrough-provider 的逐模型锁定分支(agentrouter 是passthroughModels: true→hasPerModelQuota()为true),而是应用临时连接冷却(testStatus: "unavailable"+rateLimitedUntil,绝不写入credits_exhausted/banned/expired终态),使连接在冷却过后自行恢复而不是要求手动重置凭据。disableCooling: true(#2997)的连接跳过该分支,回落到逐模型锁定(有文档化的取舍注释)。 - 同请求 combo 路由(
applyComboTargetExhaustion(),open-sse/services/combo/targetExhaustion.ts):同一守卫把连接标记进内存中的exhaustedConnections集合(key${provider}:${connectionId})。这只跳过“剩余同请求目标自身已携带该确切connectionId”的情况(getExhaustedTargetSkipReason()在provider && connectionId前提下才查集合)。普通 model-list combo 的兄弟目标不携带自己的connectionId(只在派发时从响应头X-OmniRoute-Selected-Connection-Id解析),永远无法命中该 key——这种常见情形下真正防住“剩余腿重用以刚被耗尽的账号”的并不是这个 Set,而是上方的持久化层(连接的rateLimitedUntil已在未来)叠加同一守卫对transientRateLimitedProviders的抑制:Set 未被标记时,combo.ts的allowRateLimitedConnection强制放行对 provider 剩余腿不会生效,凭据选择照常遵守rateLimitedUntil过滤器,剩余腿要么选到另一条仍合格且未被冷却的 agentrouter 连接,要么因无可用凭据而失败——绝不会强行回到刚被冷却的连接上。
8.4 两阶段设计:先状态改写,再分类
状态改写(upstreamStatusRestatement.ts)与 provider 分类规则(open-sse/config/providerErrorRules.ts 的 providerRuleRegistry)是两个都按 provider id + 文本标记来键控、但在不同位置运行、服务不同目的的独立注册表:改写很早就重写 chatCore.ts 中的 HTTP 状态码;分类规则则是在 checkFallbackError() 内部挑选 fallback reason 与锁定 scope。
关于规则文本可见性:分类规则只有在能拿到完整错误文本时才能匹配 body 标记(如 额度不足),而这仅对 FULL_TEXT_RULE_PROVIDERS allowlist 中的 provider 成立——目前只有 "agentrouter"。对其它所有内置目录 provider,checkFallbackError 只把结构化错误({code, type})交给 getProviderErrorRuleMatch,这对基于头/状态/码的规则足够,但对 body 文本标记是盲的。resolveRuleMatchBody() 负责这个选择:allowlist 内 provider 给全文,其余给结构化错误。把内置 provider 加入 FULL_TEXT_RULE_PROVIDERS 是逐 provider 的显式 opt-in,以保证不在列表上的 provider 的默认路径字节级不变。
scope 是另一个独立 opt-in:checkFallbackError 只把匹配规则的 scope 作为 fallbackResult.ruleScope 浮出,下游消费者只有对 HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist 内的 provider 才把它当作“非纯信息标注”来采纳(经 honorsRuleLockScope() 门控——今天同样只有 "agentrouter")。
#11104 — 运维声明的规则绕过两个 allowlist。运维可以通过 settings.providerErrorRules 在运行时声明逐 provider 规则(open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules),无需改文件。把运维规则也挡在 FULL_TEXT_RULE_PROVIDERS/HONORS_RULE_LOCK_SCOPE_PROVIDERS 后面——这些 allowlist 是用来保护内置目录规则默认行为的——会让设置机制对除已列 provider 外的所有 provider 失效,因为“声明规则”本身已是运维的显式 opt-in。因此 resolveRuleMatchBody() 与 honorsRuleLockScope() 都先检查 hasOperatorRuleForProvider():带运维规则的 provider 会拿到原始错误文本、其声明的 scope 会被采纳,无论它是否出现在任一 allowlist 中。
已知缺口 —— 400 不咨询 providerRuleRegistry:checkFallbackError 的 BAD_REQUEST 分支完全用自己的模式数组(MODEL_ACCESS_DENIED_PATTERNS、CONTEXT_OVERFLOW_PATTERNS 等)分类状态 400 并提前返回。带 status: 400 的内置或运维规则在语法上合法但永远不会触发。当前没有任何规则以 400 为目标,生产不受影响——但未来若加 400 规则,得先改这个分支(那将为每个已依赖模式数组行为的 provider 重新分类 400),超出“单 provider 加规则”的范围。
8.5 新增一个“误报配额状态”的网关:三步接入
- 在
statusRestatementRegistry(open-sse/config/upstreamStatusRestatement.ts)注册一个规则数组。textMarkers必须保持 provider 专属;绝不复用会与CREDITS_EXHAUSTED_SIGNALS(open-sse/services/accountFallback.ts)冲突的通用英文短语。 - 可选地在 open-sse/config/providerErrorRules.ts(
providerRuleRegistry)注册分类规则,选择正确的锁 scope(账号级配额用connection,逐模型错误用model)。该步仅对“需要完整错误文本(body 标记)”的 provider 在生产中生效:把 provider id 加入同文件的FULL_TEXT_RULE_PROVIDERS,否则checkFallbackError永远只交给规则结构化{code, type}错误,基于 body 文本的规则永远不会命中线上流量。纯按status/headers匹配的规则(如 Opencode、Minimax)无需该 opt-in。另外,若规则声明scope: "connection"且意图是真实的连接级冷却 + 同请求 combo 跳过(而非纯信息标注),把 provider id 加入同文件的HONORS_RULE_LOCK_SCOPE_PROVIDERS——它门控markAccountUnavailable()(src/sse/services/auth.ts)与applyComboTargetExhaustion()(open-sse/services/combo/targetExhaustion.ts)中的isAgentrouterConnectionQuotaScope()风格消费;不加它,scope仍会以fallbackResult.ruleScope流动,但没有任何一方会真正行动。 - 补充单元测试,仿照
tests/unit/upstream-status-restatement.test.ts与tests/unit/agentrouter-error-rules.test.ts(包含 not-permanent / not-creditsExhausted 守卫;若 provider 需要 allowlist,再补一条断言resolveRuleMatchBody()只对该 provider 返回完整文本的测试)。
无需改动 chatCore.ts、classifyError 或 combo。
8.6 Egress 分桶锁定(#10880)
EGRESS_BUCKETED_LOCK_PROVIDERS(opencode 家族)中的 provider 被当作 IP 分桶上游处理(opencode 免费层是 IP 分桶而非账号分桶,见 #9611):一个被分类为 quota_exhausted 或 rate_limit_exceeded 的状态 429,会在轮换尝试它们之前,把 allowlist 家族中“最近已知出口 IP 与失败连接一致”的所有连接一起冷却——避免 N-1 次必然失败的上游调用(与 #10460/#10525 同型)。
rate_limit_exceeded 是有意纳入的:在 markAccountUnavailable 路径上,opencode 专属规则永远不会命中(未给 checkFallbackError 传递头/体,opencode 也不在 FULL_TEXT_RULE_PROVIDERS),所以带订阅配额文本("monthly usage limit reached")的 429 会先被配额文本 fallback(buildSubscriptionQuotaFallback,1h 冷却)分类为 quota_exhausted;而无配额文本的 429(纯限速)经 status_429 规则分类为 rate_limit_exceeded,同样冷却 IP 家族。对 allowlisted provider,IP 分桶的限速与配额耗尽是同一信号。
诚实的边界(Best-effort / 非终态 / 粒度 / combo / 兄弟安全 / 专属 allowlist / 旋转两方向 / 成本):
- 尽力而为:锁会从
proxy_logs解析连接最近已知egress_ip(24h 窗口,同步,无缓存)。冷缓存(从未探测过出口 IP)或无行 → 失败连接仍按原样被冷却,只是没有兄弟被锁; - 绝不终态:冷却是一个续期中的配额窗口(
testStatus: "unavailable"),永不会从 IP 级信号推导出永久状态;disableCooling连接整支跳过; - 锁定粒度随 allowlist 家族改变:这是 scope 变更而不只是兄弟优化。opencode 是
passthroughModelsprovider,此前 429 产生逐模型锁定;现在产生连接级冷却——即使运维只跑单连接、无兄弟也一样。这正是 opencode 规则表声明的正确粒度(scope: "connection"),此前因不在HONORS_RULE_LOCK_SCOPE_PROVIDERS而从未被采纳。该分支自行写入失败连接的冷却 +backoffLevel,然后返回,逐模型块与下方通用路径不再可达; - Combo 一并覆盖:与 agentrouter 分支相同,scope 有意忽略 combo 调用方对 429 施加的
persistUnavailableState/isCombo降级。逐模型锁定不是该 scope 的更弱形式,而是错误的单元:它说明不了被耗尽的 IP,combo 轮换仍会每个兄弟烧一次必然失败的调用; - 兄弟安全:已是终态(banned/credits_exhausted)或已在更长冷却中的兄弟绝不会被覆盖;
- 专属 allowlist:扩宽
EGRESS_BUCKETED_LOCK_PROVIDERS是明确的 owner 决策,无通用接线(pattern #10334/#10419)。兄弟查询绑定同一 allowlist 而非以 SQL 字面量重复,扩宽保持一行改动; - 出口 IP 旋转,两个方向:查询窗口(24h)远宽于出口 IP 缓存 TTL(5min),因此“最近已知 IP”是历史而非现状。若连接代理在窗口内旋转,锁可能漏掉真正共享的 IP(记录的已是新的、未耗尽的那个)——对称地,它也可能冷却一个已经旋转离开耗尽 IP 的兄弟。第二种情况代价是该兄弟一个冷却窗口;两者都是基于历史查询可接受的尽力而为限制;
- 成本:仅两次对
proxy_logs的有界扫描(经idx_pl_timestamp过滤窗口),只在 429 频率发生。未新增索引(迁移 134 按 YAGNI 处理)。
九、其它弹性功能
- 19 种路由策略(priority、weighted、round-robin、context-relay、fill-first、p2c、random、least-used、cost-optimized、reset-aware、reset-window、headroom、strict-random、auto、lkgp、context-optimized、cache-optimized、fusion、pipeline)——详见 docs/routing/AUTO-COMBO.md;
- Reset-aware 路由(v3.8.0)——按配额重置时间对连接排序择优;
- 后台模式降级——Responses API 的
background: true降级为同步并附带警告; - 动态工具数量限制检测——命中工具数量上限时对 provider 退避;
- 紧急 fallback(Emergency fallback)——由
OMNIROUTE_EMERGENCY_FALLBACK控制,运维可在不重启的情况下从 Feature Flags 页面覆盖它。
十、调试速查(Debugging)
| 症状 | 排查方向 |
|---|---|
| 某 provider 所有 key 都被跳过 | 同时检查熔断器状态 和 每条连接的 rateLimitedUntil/testStatus |
| 重置窗口后 provider 仍被永久排除 | 检查是否有人直接读原始 state 而非 getStatus()/canExecute()(读路径才推进惰性恢复) |
| 一把 key 坏了、其它 key 应该正常 | 优先怀疑连接冷却,而不是熔断器 |
| 只有一个模型失败 | 优先怀疑模型锁定,而不是连接冷却 |
| 状态本应自愈却不自愈 | 检查是否存在“未来时间戳”+ 会刷新过期状态的读路径缺失;永久状态必须人工变更 |
TLS 指纹与隐身(Stealth):provider 专属的隐身策略(JA3/JA4、CCH、混淆)单独成文,见 docs/security/STEALTH_GUIDE.md。
十一、弹性测试(Phase 8 · Block C)
除弹性逻辑的单元测试外,还有三个测试在真实压力/故障条件下演练运行时(均为集成/夜间测试,不阻塞 PR):
| 测试 | 内容 | 运行方式 |
|---|---|---|
| Chaos(混沌) | 伪上游节点注入真实延迟/reset/超时/503,验证熔断器打开与恢复,且 checkFallbackError 把 503 分类为可恢复 fallback |
RUN_CHAOS_INT=1 npm run test:chaos |
| Heap-growth | 在 --expose-gc 下每个 createSSEStream 约 500 个流;堆增长超过上限即失败(OOM 守卫 #3069) |
npm run test:heap |
| k6 soak | 对 /api/monitoring/health 持续加压;p95/错误率阈值 |
k6 run tests/load/k6-soak.js(夜间) |
由 .github/workflows/nightly-resilience.yml(cron + 手动 dispatch)编排。在默认的 test:integration 中,chaos 与 heap 会自动跳过(无 RUN_CHAOS_INT/--expose-gc)。
十二、延伸阅读
- Architecture Guide — 系统架构与内部机制
- User Guide — providers、combos、CLI 集成
- Auto-Combo Engine — 组合路由策略与打分机制
- BAN_DETECTION — 账号封禁检测与
banned终态 - 弹性设置默认值归一化实现可继续研读 src/lib/resilience/settings.ts;模型锁定默认值见 src/lib/resilience/modelLockoutSettings.ts;provider 阈值档案见 open-sse/config/constants.ts。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00