Skip to content

feat: 同一 alias 的家内多条映射改为候选链主备(家内与家外同一算法)/ In-family alias mappings now form a failover chain like cross-family ones - #326

Open
SsuJojo wants to merge 1 commit into
aimod-cc:mainfrom
SsuJojo:feat/in-family-alias-chain
Open

SsuJojo wants to merge 1 commit into
aimod-cc:mainfrom
SsuJojo:feat/in-family-alias-chain

Conversation

@SsuJojo

@SsuJojo SsuJojo commented Oct 22, 2026 •

Copy link
Copy Markdown

背景 / Problem

把**同一个账号(同一提供商家族)**里的多个上游模型映射到同一个对外名(比如 free)时,只有按配置顺序的第一条生效,其余全是"影子条目":

  • 模型页上每条映射都显示、都算数;
  • 但路由层 builtin_target 只返回第一条 —— free 的流量永远打到先建的那条;
  • 第一个模型撞了限额(429)只能换账号,模型本身没有后备。

而同名映射挂在不同提供商(跨家)早已是"按账号全局优先级主备切换"的候选链 —— 家内却不是。这个 PR 把家内也接上同一个算法。

Mapping several upstream models of the same provider family to one public alias (e.g. free) today leaves only the first mapping effective; the rest are unreachable shadows — quota exhaustion on one model can only be answered by switching accounts. Same-name mappings spread across providers (cross-family) have long formed a priority failover chain; in-family ones do not. This PR gives in-family mappings the same algorithm.

方案 / Approach

候选粒度从「家」细化到「家 × 上游模型」,两层各管各的:

  • 家(route_for_forward,不变):谁有资格承载这个名字;
  • 家 × 模型(新增):这一家先发哪个模型,撞了再换下一个。

具体:

  1. catalog::target_candidates(新):按「原生承载(有则独占)→ Cline 池前缀项 → 其余映射(配置顺序)→ 旧版全局条目」给出全部候选。链首与改造前的单一答案逐字相等,存量配置行为不变;原生命中依旧短路(映射不进链)。
  2. provider_loop 位次游标(lane):账号循环的每轮内,模型作用域的失败(限额 429 / 上游 404)先在本账号内换下一位 —— 开一条新尝试明细、不吃换号预算、不触发退避 / 熔断(404 是模型的事实,不是账号健康的问题);链走完才顺延下一个账号。模型测试(pinned)不顺延。
  3. lane_targets 免费优先:已限额的目标稳定排到链尾(组内保持配置顺序,不按名称排序);全部在冷却时按原序硬试 —— 与选路第二级「恢复最早的试一次」同一取舍。
  4. 冷却逐位判定:CooldownKeys 缓存整条链(for_provider_all);rate_limit_reset_at 改为「任一目标可用 = 账号可用」,全部在冷却时报最早的恢复时间。否则会出现「第一个目标撞限额、第二个目标明明有额度,账号却被整条摘掉」的浪费。
  5. send_body 显式目标:转发层按位传 wire,"一次解析、两处消费(字节 + 冷却键)"的同源约束保持不变。
  6. send_cache 键带目标名(复核补丁):键的第四维不能是位次序号 —— 位次 → 目标是按账号算的(第 3 条的免费优先分区),同家同池的两个账号会把不同目标排在同一位次上,用序号做键时第二个账号会命中第一个账号那一档的发送体(发错模型、429 记到错误的真名)。改为 (家, 池, 降级, 目标名)。

每条候选都是不同的上游模型 = 不同的额度桶,这正是"撞一个模型的限额就换另一个模型"成立的前提。

兼容性 / Compatibility

  • 单目标配置(绝大多数):候选链只有一位,for_provider 取链首、rate_limit_reset_at 的"任一可用"退化成"那一位可用" —— 与改造前逐字等价。
  • 模型页、/v1/models、思考等级绑定、Key 白名单、会话粘性:口径均不变(别名仍按对外名去重广告一次)。

测试 / Tests

新增 15 个用例(catalog::routing::tests / core::routing::tests / upstream::provider_loop::tests):

  • 家内同 alias 多映射 → 有序链(首位 = 旧答案,锁向后兼容);
  • 原生承载独占(映射不进链);Cline 池前缀优先、其余按配置序跟随;
  • 配置顺序而非字典序;关闭的映射剔除;别家映射不进本家链;
  • 冷却:任一目标可用 = 账号可用;全冷却报最早恢复时间;过期条目算可用;for_provider 取链首 + 解析不出回落请求名;
  • 链序契约(order_lane):稳定分区(组内保持配置序)、全免费不动、全冷却按原序硬试;
  • send_cache 键契约:同一位次不同目标必须是两条缓存项(同家同池跨账号不串档)。

cargo test --release -p agent2api-server --lib:534 passed / 66 failed —— 那 66 个「临时 SQLite 目录拒绝访问(os error 5)」的环境性失败与 base 68d0a4e 逐条同名(基线 519 passed / 66 failed),新增用例全部通过。

New tests cover chain ordering (head equals the old single answer), native exclusivity, Cline pool-prefix preference, config-order stability, disabled-mapping exclusion, per-target cooldown, the free-first ordering contract and the send-body cache-key contract. The ~66 "temp SQLite directory access denied" failures on this machine are pre-existing environment issues, reproducible on a clean checkout.

已知边界 / Known limits

  • 自定义提供商:映射解析(custom_providers::bindings::resolve)仍是单答案,本 PR 不动 —— 它有独立的数据源与转发路径,摊平属于同构的后续工作;
  • 有状态路径(CatPaw):维持单目标解析 —— 该路径没有账号内重试机制(上游无轮换信号,见 attempt_stateful 的说明),先不引入。

行为不变,只是"家内多模型同名"暂时仍取首条;后续如需可以按同一模式补上。

  • Custom providers: mapping resolution stays single-answer (separate data source and forward path; same-shape follow-up).
  • Stateful path (CatPaw): stays single-target (no in-account retry mechanism exists there by design).

验证方式 / How to verify

同一账号(如小浣熊)勾选两个模型,各加一条同名映射(如 free):

  1. model: "free" 请求 → 发送主位模型;
  2. 把主位模型在账号上打满(或临时给它一条 429)→ 下一条 free 请求自动落到第二位模型,请求日志出现「家内候选顺延 → <模型>」与两条尝试明细;
  3. 第二位也限额 → 账号页两个真名都在冷却,请求顺延到下一个账号。

In-family alias mappings now form an ordered failover chain,
same algorithm as the cross-family one.

问题:把同一个账号(同一提供商家族)里的多个上游模型映射到同一个
对外名(如 free)时,只有按配置顺序的第一条生效,其余全是影子条目
—— 撞了限额只能换账号,模型本身没有后备。同名映射挂在不同提供商
(跨家)早已是按账号优先级主备切换的候选链,家内却不是。

Fix: 把家内的多条映射摊平成一条有序候选链,与跨家同算法:

- `catalog::target_candidates`:按「原生承载(有则独占)→ Cline 池
  前缀项 → 其余映射(配置顺序)→ 旧版全局条目」给出全部候选;链首
  与改造前的单一答案逐字相等,存量配置行为不变。
- `provider_loop`:账号循环内新增位次游标(lane)。模型作用域的失败
  (限额 429 / 上游 404)先在本账号内换下一位(开一条新明细、不吃
  换号预算、不触发退避 / 熔断),链走完才顺延下一个账号。模型测试
  (pinned)不顺延。
- `lane_targets`:已限额的目标稳定排到链尾(免费优先);全部在冷却
  时按原序硬试 —— 与选路第二级「恢复最早的试一次」同一取舍。
- `CooldownKeys` 缓存整条候选链(`for_provider_all`);
  `rate_limit_reset_at` 改为「任一目标可用 = 账号可用」,全部在冷却
  时报最早的恢复时间。单目标配置与改造前逐字等价。
- `send_body` 新增 `wire` 参数:转发层按位传目标,「一次解析、两处
  消费」的同源约束保持不变。

测试:候选链顺序 / 原生独占 / 池前缀优先 / 配置序 / 关闭映射剔除 /
冷却逐位判定共 11 个新用例;单目标回归路径被既有测试覆盖。
(本机跑全套时另有约 66 个「临时 SQLite 目录拒绝访问」的环境性失败,
与本次改动无关,干净基线同样失败。)

已知边界:自定义提供商与有状态(CatPaw)路径维持单目标解析,行为
不变(见代码注释)。

Problem: mapping several upstream models of the SAME provider family
to one public alias (e.g. "free") left only the first mapping effective;
the rest were unreachable shadows — quota exhaustion on one model could
only be answered by switching accounts. Same-name mappings spread across
providers (cross-family) have long been a priority failover chain;
in-family ones were not.

Fix: flatten in-family mappings into an ordered candidate chain that
follows the same algorithm as the cross-family one:

- `catalog::target_candidates` returns all candidates ordered as
  native (exclusive when present) → Cline pool-prefixed → remaining
  mappings in config order → legacy global entries. The chain head
  equals the previous single answer, so existing configs behave
  identically.
- `provider_loop` gains a per-account lane cursor. Model-scoped
  failures (429 / upstream 404) advance to the next target within the
  same account (new attempt entry, no switch budget consumed, no
  backoff/breaker marking) before falling through to account failover.
  Pinned model tests do not advance.
- `lane_targets` moves rate-limited targets to the tail (free first);
  when everything is cooled the chain is tried in original order —
  same trade-off as the selector's second tier.
- `CooldownKeys` now caches the whole chain (`for_provider_all`);
  `rate_limit_reset_at` treats "any target free" as usable and reports
  the earliest reset when all are cooled. Single-target configs are
  byte-for-byte equivalent to before.
- `send_body` takes an explicit target: one resolution, two consumers,
  unchanged.

Tests: 11 new cases covering chain ordering, native exclusivity, pool
prefix preference, config order, disabled-mapping exclusion and
per-target cooldown; single-target regression paths are covered by
existing tests. (About 66 pre-existing environment failures about
"temp SQLite directory access denied" occur on this machine for a
clean baseline too; unrelated to this change.)

Known limits: custom providers and the stateful (CatPaw) path keep
single-target resolution; behavior unchanged.

--- 复核补丁(同一 PR 内折叠)───────────────────────────────────

`send_cache` 的键原先是 (家, 池, 降级, lane),但 lane → 目标的对应关系是
**按账号**算的(`lane_targets` 的「免费优先」分区读该账号自己的冷却状态)。
同家同池的两个账号会把不同目标排在同一位次上,用序号做键时第二个账号会命中
第一个账号那一档的发送体 —— 「这一家先发哪个模型」被上一个账号的冷却状态
决定,发出去的模型名与本次 `lane_targets` 的结论不符,429 记账也跟着记到错误
的真名上(原注释「候选链按家算,位次相同意味着发送名相同」正是不成立的那句:
解析按家,排序按账号)。现改为键带**这一档实际要发的上游模型名**
(`send_cache_key` 收口):位次相同且目标相同才复用,目标相同即发送体相同。

顺带两处:

- **「敏」命中表按家去重**:`send_body` 新增 `count_redactions`(+ 循环外的
  `redaction_counted`)。换位次 / 翻转降级标记 / 换池都会重算发送体、重过一遍
  脱敏层,命中的却是同一份内容,计数不该跟着翻倍 —— payload 模块头原本就写着
  「同一家重复发送时不重复计」,原先只是靠 `send_cache` 的旧键形状顺带成立。
- **`lane_targets` 的排序抽成纯函数 `order_lane`**,补 4 个用例:稳定分区
  (组内保持配置序)、全免费不动、全冷却按原序硬试,以及这条缓存键契约
  (同一位次不同目标必须是两条缓存项)。

测试口径:`cargo test --release -p agent2api-server --lib` → 534 passed /
66 failed,失败集与 base 68d0a4e 逐条同名(均为「临时 SQLite 目录拒绝访问」的
环境性失败),新增 4 个用例全绿。

Review follow-up folded into this commit: the per-family send-body cache key
was (family, pool, degraded, lane index), but lane -> target is resolved per
account, so two accounts in the same family/pool could place different targets
at the same lane index and the second account reused the first account's body
(wrong model name sent, and the 429 cooldown booked under the wrong real
name). The key now carries the target name itself (send_cache_key). Also
deduped the redaction-hit accounting per family (new count_redactions
parameter) and extracted the free-first ordering into a pure order_lane()
covered by 4 new tests.
@SsuJojo
SsuJojo force-pushed the feat/in-family-alias-chain branch from e6a312b to 18fbbfa Compare October 22, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant