Repository navigation
feat: 同一 alias 的家内多条映射改为候选链主备(家内与家外同一算法)/ In-family alias mappings now form a failover chain like cross-family ones - #326
Open
SsuJojo wants to merge 1 commit into
Conversation
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
force-pushed
the
feat/in-family-alias-chain
branch
from
October 22, 2026 09:15
e6a312b to
18fbbfa
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景 / Problem
把**同一个账号(同一提供商家族)**里的多个上游模型映射到同一个对外名(比如
free)时,只有按配置顺序的第一条生效,其余全是"影子条目":builtin_target只返回第一条 ——free的流量永远打到先建的那条;而同名映射挂在不同提供商(跨家)早已是"按账号全局优先级主备切换"的候选链 —— 家内却不是。这个 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,不变):谁有资格承载这个名字;具体:
catalog::target_candidates(新):按「原生承载(有则独占)→ Cline 池前缀项 → 其余映射(配置顺序)→ 旧版全局条目」给出全部候选。链首与改造前的单一答案逐字相等,存量配置行为不变;原生命中依旧短路(映射不进链)。provider_loop位次游标(lane):账号循环的每轮内,模型作用域的失败(限额 429 / 上游 404)先在本账号内换下一位 —— 开一条新尝试明细、不吃换号预算、不触发退避 / 熔断(404 是模型的事实,不是账号健康的问题);链走完才顺延下一个账号。模型测试(pinned)不顺延。lane_targets免费优先:已限额的目标稳定排到链尾(组内保持配置顺序,不按名称排序);全部在冷却时按原序硬试 —— 与选路第二级「恢复最早的试一次」同一取舍。CooldownKeys缓存整条链(for_provider_all);rate_limit_reset_at改为「任一目标可用 = 账号可用」,全部在冷却时报最早的恢复时间。否则会出现「第一个目标撞限额、第二个目标明明有额度,账号却被整条摘掉」的浪费。send_body显式目标:转发层按位传wire,"一次解析、两处消费(字节 + 冷却键)"的同源约束保持不变。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):for_provider取链首 + 解析不出回落请求名;order_lane):稳定分区(组内保持配置序)、全免费不动、全冷却按原序硬试;send_cache键契约:同一位次不同目标必须是两条缓存项(同家同池跨账号不串档)。cargo test --release -p agent2api-server --lib:534 passed / 66 failed —— 那 66 个「临时 SQLite 目录拒绝访问(os error 5)」的环境性失败与 base68d0a4e逐条同名(基线 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 不动 —— 它有独立的数据源与转发路径,摊平属于同构的后续工作;attempt_stateful的说明),先不引入。行为不变,只是"家内多模型同名"暂时仍取首条;后续如需可以按同一模式补上。
验证方式 / How to verify
同一账号(如小浣熊)勾选两个模型,各加一条同名映射(如
free):model: "free"请求 → 发送主位模型;free请求自动落到第二位模型,请求日志出现「家内候选顺延 → <模型>」与两条尝试明细;