Skip to content

Commit f428e33

Browse files
committed
feat(executor): 11140 账号受限改为冷却 + 跳渠道,不再永久禁用
CodeBuddy 的 403/11140「request illegal」是上游按账号(多号同源时按出口 IP) 拉黑,不是 session 失效,也不是请求形状问题——techysy/10router、yetone/magpie、 maiphucgiang/codebuddy2api 三个项目的实测结论一致:换模型/换形态/换代理都无效, 只能等上游解除或换号。 旧实现把它归 DEAD:永久 disabled + 人工 revive,且一次请求会连打 3 个号,每个 都留一条风控记录(实测 08:21 连打 3 个号、08:38 再来一轮、08:41 新号 3 分钟被封)。 - 新增 ErrKind.RESTRICTED:401/403 命中 11140 时先于 DEAD 判定 - 账号级 2h 冷却(上游 Retry-After 采用、夹 24h),到期自动回候选,不写 disabled - 当前请求 `_skip_provider` 跳过该渠道其余凭证,继续试其它渠道 - 窗口内(10min)第二次命中升级为渠道级 24h 暂停(`cool_provider`),只冷却不禁用 - 统计新增受控类型 `account_restricted`(前端标签「账号被上游限制」) - 保留 DEAD + disabled_reason:真 session 失效仍按原语义硬禁用 - README / README.en / TECHNICAL §3.2 同步 实测(本机,真实受限账号):单次请求只打 1 个号 → 该号 cooling +2h 且 disabled=0、落 account_restricted;13 秒后第二次命中 → 6 个号全部冷却 24h。
1 parent bc3670f commit f428e33

14 files changed

Lines changed: 395 additions & 22 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ Four tabs (channel / model / user / credential) share one table (channel / reque
512512

513513
**Two latency scopes**: successful requests only. Average = `latency_sum ÷ successes` (hourly rollup); percentile from per-request detail (90 days). `NULL` `ttfb_ms` ranks independently in each column. No samples → `—`.
514514

515-
**Failure attribution**: panel below group table, `GET /api/stats/errors` (operator+) aggregates `ok=0` detail by `(channel, model, error_type)` → failure count, **unrescued count**, last failure time (descending, `limit` ≤ 5000). **Failure count = per-attempt**: every upstream rejection lands row, even when retry on another credential succeeds; failure row whose `request_id` also has success row for **same user** counts as rescued; `unrescued = failures − rescued` = **failure attempts never rescued** (a request failing twice counts twice; not number of lost requests). Hard credential failure (DEAD, e.g. 401) also lands a `credential_unavailable` row, counted as rescued if retry succeeds elsewhere; terminal row not duplicated once rotation exhausted. Click row → detail with *failed only* (`GET /api/stats/events?ok=0`). Admin/operator only. `/metrics` `upstream_errors_total` still in-process counter; not merged.
515+
**Failure attribution**: panel below group table, `GET /api/stats/errors` (operator+) aggregates `ok=0` detail by `(channel, model, error_type)` → failure count, **unrescued count**, last failure time (descending, `limit` ≤ 5000). **Failure count = per-attempt**: every upstream rejection lands row, even when retry on another credential succeeds; failure row whose `request_id` also has success row for **same user** counts as rescued; `unrescued = failures − rescued` = **failure attempts never rescued** (a request failing twice counts twice; not number of lost requests). Hard credential failure (DEAD, e.g. 401) also lands a `credential_unavailable` row; an upstream account restriction (CodeBuddy 11140) lands `account_restricted`, cools the account for 2h and stops rotating within that channel, escalating to a whole-channel pause on a second hit in the window; counted as rescued if retry succeeds elsewhere; terminal row not duplicated once rotation exhausted. Click row → detail with *failed only* (`GET /api/stats/events?ok=0`). Admin/operator only. `/metrics` `upstream_errors_total` still in-process counter; not merged.
516516

517517
**Row drill-down**: clicking group row filters detail by that row (channel / model / credential; user dimension uses scope), stacking conditions. `GET /api/stats/events` accepts `provider` / `model` / `credential_id` (AND) + `ok=0`.
518518

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ tabs 切四维(渠道 / 模型 / 用户 / 凭证),共用一表(渠道 /
514514

515515
**延迟两类口径**:只统计成功请求。平均 = `latency_sum ÷ 成功数`(小时汇总);分位从逐请求明细算(仅 90 天)。`ttfb_ms` = `NULL` 的两列各自独立排名。无样本 → `—`。
516516

517-
**失败归因**:`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` → 失败次数 / **未救回数** / 最近失败时刻(降序,`limit` ≤ 5000)。**失败数 = 尝试口径**:换号重试每次上游拒绝各落一行;同用户同 `request_id` 另有成功行 = 已救回。`未救回 = 失败数 − 救回数`(同请求多次失败算多次,≠ 受损请求数)。凭证 401 硬禁用(DEAD)也落 `credential_unavailable`;换号成功算已救回;轮换耗尽不重复落收尾行。点行钻取 +「只看失败」(`GET /api/stats/events?ok=0`)。仅 admin / operator。`/metrics` 的 `upstream_errors_total` 取进程内计数,不合并。
517+
**失败归因**:`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` → 失败次数 / **未救回数** / 最近失败时刻(降序,`limit` ≤ 5000)。**失败数 = 尝试口径**:换号重试每次上游拒绝各落一行;同用户同 `request_id` 另有成功行 = 已救回。`未救回 = 失败数 − 救回数`(同请求多次失败算多次,≠ 受损请求数)。凭证 401 硬禁用(DEAD)也落 `credential_unavailable`;账号被上游风控限制(CodeBuddy 11140)落 `account_restricted`,账号级 2h 冷却、当前请求不再换号,同渠道窗口内二次命中整渠道退避;换号成功算已救回;轮换耗尽不重复落收尾行。点行钻取 +「只看失败」(`GET /api/stats/events?ok=0`)。仅 admin / operator。`/metrics` 的 `upstream_errors_total` 取进程内计数,不合并。
518518

519519
**点行钻取**:点分组表任一行 → 明细按该行过滤(渠道 / 模型 / 凭证;用户维度走作用域参数),条件可叠加。`GET /api/stats/events` 接受 `provider` / `model` / `credential_id`(AND)+ `ok=0`。
520520

‎TECHNICAL.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ class ErrKind(StrEnum):
9191
PLAN = "plan" # 权益耗尽(1005)→ 冷却 12h
9292
CREDIT = "credit" # 余额不足(402/14018)→ 冷却到次日 04:00(等签到恢复)
9393
SOFT = "soft" # 限流/404 → 冷却 60s,不累计错误数
94-
DEAD = "dead" # session 失效 / 账号被上游限制 → 硬禁用 disabled=1
94+
DEAD = "dead" # session 失效(重登可恢复)→ 硬禁用 disabled=1
95+
RESTRICTED = "restricted" # 账号被上游风控限制(11140)→ 账号级 2h 冷却,当前请求不再换号
9596
OTHER = "other" # 其他 4xx/5xx → 累计,连续 3 次 → 冷却 10m
9697
INVALID = "invalid" # 请求无效(模型不存在等)→ 不冷却凭证,跳过该上游;全拒 → 400
9798
MODEL = "model" # 该模型用量超限(429+6004)→ 只冷却 (凭证, 模型),10m 起翻倍封顶 2h
@@ -105,7 +106,14 @@ MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY})
105106

106107
`MODEL_SCOPED_KINDS` 只写 `credential_model_cooldowns`(§6.3),不碰账号级 `cooling_until`。账号级冷却出现 → 清该凭证模型级条目;不清 → 切模型绕过账号级限流。
107108

108-
**硬禁用的可操作原因(`disabled_reason`)**:401/403 不都是 session 过期——CodeBuddy 11140「request illegal」的 displayMsg 是「账号存在异常行为,已被限制使用」,重登无效,只能申诉或换号。DEAD 时 executor 把 `UpstreamHTTPError.body` 交给 provider 的 `disabled_reason()` 识别(CodeBuddy 已实现),命中就写「账号被上游限制(账号异常行为),需提交申诉;重新登录无效」;识别不到(其他渠道、流内错误、无 body)回落历史文案 `session dead`。管理台 `CredentialsPage` 原样展示该字段,写错会把人引向错误的修复动作。只回受控文案,不拼接上游正文。
109+
**账号被上游风控限制(RESTRICTED,CodeBuddy 11140)**:`403 request illegal` 的字面像「这条请求非法」,实际是上游按账号(多号同源时按出口 IP/设备)拉黑——换模型、换请求形态、换代理都无效(techysy/10router、yetone/magpie、maiphucgiang/codebuddy2api 三个项目的实测结论一致)。
110+
111+
- **必须拦在 `401/403 → DEAD` 之前**:判 DEAD 会写不可逆的 `disabled=1`,而这类限制由上游自行解除,写成禁用就变成「必须有人点恢复」,还会诱使运维反复 revive 去撞。
112+
- **账号级 2h 冷却**(`RESTRICTED_COOLDOWN_SECONDS`),上游给 `Retry-After` 时采用并夹在 24h 内;到期自动回到候选,不做任何人工动作。
113+
- **当前请求不再换号**:`executor` 命中后 `_skip_provider` 跳过该渠道剩余凭证,继续试其它渠道。逐个号换着撞会扩大标记面(实测一次请求连打 3 个号全 403,新号加入 3 分钟即被封)。
114+
- **窗口内第二次命中升级为渠道级暂停 24h**(`_pause_provider_on_burst` + `cool_provider`):只冷却、不写 `disabled`,所有号一起退避。
115+
116+
**硬禁用的可操作原因(`disabled_reason`)**:真 session 失效(401 / token 过期)仍是 DEAD,`disabled_reason` 写「session dead」或 provider 识别出的具体原因(CodeBuddy 的 `disabled_reason()` 识别 11140 文案,作为非 RESTRICTED 入口的兜底)。管理台 `CredentialsPage` 原样展示该字段,写错会把人引向错误的修复动作。只回受控文案,不拼接上游正文。
109117

110118
**排队语义(QUEUE)**:上游回排队中 + 建议等待秒数 = 号满,等 N 秒重打。非账号故障、非请求错误。
111119

@@ -655,7 +663,7 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k
655663
- **分组表合并列**:`请求`(数 · 成功率)、`Token`(输入/输出 · 命中率)、`速度`(正文/总产出)、`平均延迟`(耗时/首字)、`P95 延迟`,14 → 8 列。排序走 `SortMenuHead`,子列键即 `GROUP_SORT_COLUMNS` 白名单键。延迟拆两列:均值读小时汇总、P95 读明细,口径与覆盖期不同。
656664
- **明细合并列**:`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 **同一片段**。
657665
- **请求 ID**:`X-Request-ID`(`webapp/request_id.py` 最外层)。入站合法(`[A-Za-z0-9._:-]{1,128}`)沿用,否则 `uuid4().hex`;同一中间件把会话标识头写进 contextvar 供粘性读取;ID 经 `request_context` 传进 `usage_events.request_id`。**不透传上游**。
658-
- **失败归因**(`GET /api/stats/errors`,operator+):按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 明细。`failures` 是**尝试**口径(每次上游拒绝一行);`rescued` = 同用户同 `request_id` 另有成功行的行数(不叠加时间窗,走 `idx_usage_request`);`failures − rescued` = 未被救回的失败尝试数(同一请求多次失败算多次)。DEAD(硬禁用)额外落一行 `credential_unavailable`;轮换耗尽时不再重复落收尾行,避免 `requests` 与 Key 日额度双计。
666+
- **失败归因**(`GET /api/stats/errors`,operator+):按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 明细。`failures` 是**尝试**口径(每次上游拒绝一行);`rescued` = 同用户同 `request_id` 另有成功行的行数(不叠加时间窗,走 `idx_usage_request`);`failures − rescued` = 未被救回的失败尝试数(同一请求多次失败算多次)。DEAD(硬禁用)额外落一行 `credential_unavailable`;账号被上游风控限制(11140)落 `account_restricted` 并整渠道退避,不写 `disabled`;轮换耗尽时不再重复落收尾行,避免 `requests` 与 Key 日额度双计。
659667
- **延迟均值只算成功请求**(`SUM(latency_ms WHERE ok=1)` / `ok_count`)。
660668
- **图表时序指标**(`timeline` / `model-timeline`):`METRIC_KINDS` 一张表定语义,查询侧固定取齐 10 列聚合(`_METRIC_AGGREGATES`),由 `_metric_value(metric, row)` 归一。派生指标**在聚合后相除**(成功率 `ok_count/requests`、缓存命中率 `cached/input`、单请求成本 `cost/requests`),不是逐小时比率的平均——后者会被低量小时等权带偏。无样本一律给 `null` 而非 0(均值给 0 会画成「0 ms」的假低谷,比率给 0 会显示成「0%」)。
661669
- **图表时间轴稠密**:`usage_hourly` 只在有请求时才有行,稀疏下发会让前端把「空档 5 小时」和「相邻 1 小时」画成一样宽(分类轴压缩时间)。`_dense_hours()` 按 `since`/`until` 补齐小时桶(都不给则补首末观测之间),跨度超 `MAX_DENSE_HOURS`(92 天)时退回观测小时。缺失桶取值由 `_empty_value()` 按语义给:可加量补 0,均值/比率/成本补 `null`(前端 `connectNulls={false}` 断线)。前端 X 轴用 `type="number"` + `scale="time"` + 毫秒时间戳,按真实时间定位。

‎src/db/repo.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,28 @@ def save_error(self, credential_id: str, outcome: ErrorOutcome) -> None:
357357
if outcome.cooling_until is not None:
358358
_clear_model_cooldowns(conn, credential_id)
359359

360+
def cool_provider(self, provider: str, *, until: int) -> int:
361+
"""渠道级冷却:把该渠道所有凭证的账号级冷却一起推后,返回受影响行数。
362+
363+
用于「整批同源账号被上游风控限制」(CodeBuddy 11140):逐个号冷却挡不住
364+
下一个请求换同渠道另一个号继续撞,每次都在上游多留一条风控记录。只写
365+
`cooling_until`,不动 `disabled`——禁用是不可逆的人工状态,而这类限制
366+
会由上游自行解除,到期必须能自动回到候选。
367+
368+
只在比现有冷却更晚时覆盖(`cooling_until < until`):渠道级暂停不该
369+
缩短某个号已经更晚的冷却。账号级冷却出现要清掉模型级条目,否则模型级
370+
豁免会泄漏成「换模型绕过账号级限制」。
371+
"""
372+
with self._db.transaction() as conn:
373+
cursor = conn.execute(
374+
"UPDATE credentials SET cooling_until = ?, err_count = 0 "
375+
"WHERE provider = ? AND (cooling_until IS NULL OR cooling_until < ?)",
376+
(until, provider, until))
377+
conn.execute(
378+
"DELETE FROM credential_model_cooldowns WHERE credential_id IN "
379+
"(SELECT id FROM credentials WHERE provider = ?)", (provider,))
380+
return cursor.rowcount
381+
360382
def save_success(self, credential_id: str, *, model: str | None = None) -> None:
361383
"""成功:清账号级错误累计;`model` 命中负缓存条目时一并清除。
362384

0 commit comments

Comments
 (0)