Skip to content

feat(providers): cc-switch 式供应商自动故障转移(同厂商同模型) - #385

Open
coder-hhx wants to merge 10 commits into
mainfrom
feat/provider-auto-failover
Open

feat(providers): cc-switch 式供应商自动故障转移(同厂商同模型)#385
coder-hhx wants to merge 10 commits into
mainfrom
feat/provider-auto-failover

Conversation

@coder-hhx

@coder-hhx coder-hhx commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Closes #384

Summary

修复自动故障转移设置无法持久化的 bug,并将故障转移重构为 cc-switch 语义的供应商级设计:

  • 持久化(SQLite,双端同步)modelFailover 此前未接入任何持久化通道,退出设置页重进即丢失。现持久化到桌面配置库新表 model_failover_settingssettings_load_all 读取、settings_save_model_failover 写入),并加入 Gateway settings-sync 协议(payload 类型 / 字段白名单 / build / apply 四处),GUI 与 WebUI 共享同一份配置:桌面改动推快照给 WebUI,WebUI 改动经 SettingsUpdate 通道落桌面 SQLite 并回推。localStorage 仅保留一次性旧数据迁移回退。
  • 供应商级队列(cc-switch 语义):failover 只换供应商、不换模型——失败请求用当前对话的模型 ID 原样发给队列里下一个同厂商供应商;未激活该模型的供应商在本轮自动跳过。队列旧格式(供应商+模型对)自动迁移为供应商 id 并去重。
  • 运行时:按 provider::model 维度的熔断器(连续失败阈值 → 熔断 → 冷却 → 半开探测);错误分类器仅对供应商故障类错误(5xx/网络/鉴权/配额)转移,客户端请求类错误(如上下文超限)不转移;流包装器在首个可见内容前缓冲事件,丢弃的尝试对用户不可见;成功应答的供应商粘性保持并写回对话选择。
  • UI:故障转移配置位于供应商配置右上角设置按钮打开的「高级设置」抽屉(原「自定义设置」更名),跟随当前厂商标签页;供应商队列按名称 + baseUrl 展示,熔断参数纵向排列;zh/en 文案更新;修复 {vendor} 占位符多次出现时只替换首个的问题。

Change scope

  • Modules: agent-gui(含 src-tauri)/ agent-gateway(web 前端)
  • Key paths:
    • crates/agent-gui/src-tauri/src/commands/config/settings/(新表 model_failover_settings + 读写命令 + 网关快照)
    • crates/agent-gui/src/lib/providers/runtime/providerFailover.ts(新增:熔断器 + 错误分类 + 流包装器)
    • crates/agent-gui/src/lib/settings/{index,storage,sync}.ts(队列模型 + 迁移 + SQLite 持久化 + 同步协议)
    • crates/agent-gui/src/pages/chat/runtime/{providerRuntimeConfig,useSendChatTurn}.ts(每轮 failover 计划)
    • crates/agent-gui/src/lib/chat/runner/agentRunner.ts(候选装配 + withProviderFailover 接入)
    • crates/agent-gui/src/pages/settings/ProvidersSection.tsx + web 端孪生 + 双端 i18n / sync.ts

Screenshots / preview

供应商配置右上角设置按钮 →「高级设置」抽屉——供应商级故障转移队列:

image

实测转移过程:将 P1 供应商(RightCode sale)的 API key 置为无效后发起对话,状态栏显示「第 1 轮:RightCode(sale)· claude-fable-5 不可用,正在切换到 RightCode · claude-fable-5…」,随后由 P2 供应商以同一模型完成应答,对话选择自动切到应答的供应商。

28e03389b75e80a37b17d8fce6edf06c

Verification

  • cargo check / cargo test --lib initialize_schema ✅(新表 schema 测试通过)
  • cd crates/agent-gui && ./node_modules/.bin/tsc --noEmit ✅(web 端同样通过)
  • biome check 两端 0 error ✅
  • node --test test/settings/model-failover-normalize.test.mjs test/providers/provider-failover-plan.test.mjs test/providers/provider-failover.test.mjs — 30 项通过(熔断器/流包装器 16、计划构建 6、归一化+迁移 8)
  • 同步回归测试(review 建议的三项):modelFailover 变更进 buildGatewaySettingsSyncUpdatePayload();双端 build→apply round-trip(部分 payload 不覆盖现有配置);同步来的配置可被 buildModelFailoverPlan() 构造实际候选 — GUI normalization.test.mjs + web web-settings.test.mjs + provider-failover-plan.test.mjs
  • node scripts/check-mirror.mjs ✅(115 文件)
  • 真机验证:pnpm tauri dev 启动桌面端,复现原 bug 路径(退出设置→重进),配置保留;旧数据自动迁移;实际断供触发转移成功(见截图说明)

Pre-submit checklist

  • A requirement issue is linked (or this is a trivial fix that needs no issue, as explained in the summary).
  • Synced with the target branch; no merge conflicts.
  • The change is focused, with no unrelated modifications.
  • No secrets, tokens, or personal data included.
  • Docs are updated for changes affecting user behavior, deployment, or configuration.

🤖 Generated with Claude Code

…viders

Adds automatic provider failover for chat requests, configured per vendor
tab in provider settings and modeled after cc-switch's proxy-side design:

- Failover switches providers, never models: the failed request is re-sent
  to the next provider in the queue with the same model id the conversation
  was using; queued providers without that model active are skipped.
- Per-target circuit breaker (consecutive-failure threshold, cooldown with
  half-open probe) keyed by provider::model, plus an error classifier that
  only fails over on provider-fault-class errors (5xx/network/auth/quota),
  never on client-request-class errors (context overflow etc.).
- Stream wrapper buffers events until first committed content so consumers
  never see output from a discarded attempt; sticky winner keeps follow-up
  rounds on the provider that answered, and onSwitched pins the conversation
  selection to it.
- Settings: modelFailover is now persisted (it previously round-tripped
  through neither the SQLite nor the localStorage channel, so the toggle
  and queue silently reset on every settings reopen); queue entries are
  provider ids with legacy {customProviderId, model} entries migrated by
  collapsing to their provider id.
- UI: failover card under each vendor tab manages an ordered provider
  queue with breaker knobs; i18n zh/en updated, {vendor} placeholder now
  replaced at every occurrence.

Covered by unit tests for the breaker/stream wrapper, plan builder, and
settings normalization/migration; verified end-to-end in the running app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@StackCairn
StackCairn marked this pull request as draft August 5, 2026 13:11
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

PR governance checks passed. Awaiting human review.

coder-hhx and others added 2 commits August 5, 2026 21:19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coder-hhx
coder-hhx marked this pull request as ready for review August 5, 2026 13:27
@FlowerRealm

Copy link
Copy Markdown
Contributor

没啥希望通过, 仓库所有者表示希望更简洁的provider, 如有此需求推荐安装一个cc-switch

@FlowerRealm

Copy link
Copy Markdown
Contributor

pr可以留着, 之后有插件体系了可以放插件里

…s drawer

The failover card no longer renders under each vendor's provider list; it
now lives in the drawer behind the gear button in the providers header,
scoped to the active vendor tab. The drawer is renamed from "Custom
Settings" to "Advanced Settings" (自定义设置 → 高级设置) in zh/en on both
the GUI and WebUI ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ouoiouo pushed a commit to ouoiouo/LiveAgent that referenced this pull request Aug 8, 2026
…pstream PR Stack-Cairn#385)

- 修复 modelFailover 设置无法持久化的问题(接入 localStorage 通道)
- cc-switch 语义供应商级故障转移:只换供应商不换模型,未激活跳过
- provider::model 维度熔断器(失败阈值→熔断→冷却→半开探测)
- 流包装器在首个可见内容前缓冲,丢弃尝试对用户不可见
- 成功应答供应商粘性保持并写回对话选择
ouoiouo pushed a commit to ouoiouo/LiveAgent that referenced this pull request Aug 8, 2026
coder-hhx and others added 2 commits August 8, 2026 13:06
The 3-column grid was cramped inside the 440px advanced-settings drawer;
one field per row reads better there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hint ("未选择时,标题生成会使用当前对话使用的模型") described the
title-model picker but sat under the drawer heading; it now renders
between the 标题生成模型 label and the picker on both ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@su-fen

su-fen commented Aug 8, 2026

Copy link
Copy Markdown
Member

审核结论:当前不建议直接合入,存在 1 个功能阻断。

阻断:WebUI 的 modelFailover 没有进入 Gateway settings-sync

WebUI 已在 ProvidersSection.tsx 中通过 updateModelFailover() 修改 settings.modelFailover,但 GUI/WebUI 两端的同步协议均未包含该字段:

  • GatewaySettingsSyncPayload 没有 modelFailover
  • GATEWAY_SETTINGS_SYNC_FIELDS 没有 modelFailover
  • buildGatewaySettingsSyncPayload() 没有序列化 settings.modelFailover
  • applyGatewaySettingsSyncPayload() 没有应用远端 modelFailover

相关位置:

这会导致 WebUI 上的开关、队列和熔断参数只保存在浏览器本地,真正执行聊天请求的桌面 Agent 收不到配置;桌面端修改也不会同步回 WebUI。界面会显示已保存,但运行时故障转移不会按 WebUI 配置生效。

建议补齐双端同步协议,并增加以下回归测试:

  1. 修改 modelFailover 后,buildGatewaySettingsSyncUpdatePayload() 必须包含该字段。
  2. GUI/WebUI 的 modelFailover 可以完整 round-trip。
  3. WebUI 配置同步到桌面后,可以被 buildModelFailoverPlan() 构造成实际候选计划。

其余审核结果:当前 head cd75640c 的 8 项远端检查全部通过;我也在最新 main27a2d928)上完成了无冲突合并模拟,双端 production build、完整前端测试、mirror check 和 git diff --check 均通过。修复上述同步阻断并补测试后,再重新跑 CI 即可复审。

coder-hhx and others added 2 commits August 8, 2026 22:02
…ync it

localStorage made the failover config per-webview state, so the GUI and
WebUI could silently diverge. Move it to the desktop config DB and the
gateway sync protocol so both ends share one copy:

- Rust: new model_failover_settings table (same single-row payload_json
  shape as memory_settings), settings_load_all returns it,
  settings_save_model_failover persists it, and the gateway settings
  snapshot includes it so remote WebUI clients receive and can update it.
- GUI storage.ts: loads from SQLite (falling back to the pre-SQLite
  localStorage copy for migration) and saves through the new command;
  the localStorage channel no longer carries modelFailover.
- sync.ts (both ends): modelFailover joins the sync payload type, the
  diffed field list, the full-snapshot builder, and the incoming-payload
  apply, so edits from either end propagate like memory/mcp settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression tests for the three scenarios raised in review:
- a modelFailover edit shows up in buildGatewaySettingsSyncUpdatePayload
  (and an unchanged config produces no entry),
- the config round-trips build → apply across ends, with a partial
  payload leaving the receiver's config untouched,
- a config applied from a synced payload feeds buildModelFailoverPlan
  and yields real fallback candidates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coder-hhx

coder-hhx commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

@su-fen 感谢细致的审核。你指出的阻断在你审核的 head(cd75640c)上确实存在——当时 modelFailover 还在 localStorage 通道里,双端各存一份。这个问题已在后续两个提交中修复:

5495d9dc — 存储与同步协议补齐(审核期间恰好在做这个改动):

  • 桌面端新增 model_failover_settings SQLite 表,settings_load_all / settings_save_model_failover 读写,localStorage 仅保留一次性迁移回退
  • 双端 sync.tsGatewaySettingsSyncPayloadGATEWAY_SETTINGS_SYNC_FIELDSbuildGatewaySettingsSyncPayload()applyGatewaySettingsSyncPayload() 四处均已加入 modelFailover
  • Rust 网关快照(load_gateway_settings_sync_snapshot)包含 modelFailover,WebUI 连接即收到桌面配置;WebUI 修改经既有 SettingsUpdate 通道落桌面 SQLite 并回推

b3e4565f — 按建议补的三项回归测试

  1. modelFailover 变更后 buildGatewaySettingsSyncUpdatePayload() 包含该字段(未变更则不包含)— normalization.test.mjs
  2. 双端 build → apply 完整 round-trip,部分 payload 不覆盖接收端现有配置 — GUI normalization.test.mjs + web web-settings.test.mjs
  3. 同步来的配置能被 buildModelFailoverPlan() 构造出实际候选 — provider-failover-plan.test.mjs

麻烦基于最新 head 复审。

🤖 Generated with Claude Code

@su-fen

su-fen commented Aug 8, 2026

Copy link
Copy Markdown
Member

审查结论:暂不建议直接合入,建议 Request changes。

发现两个需要先修复的行为问题:

  1. 纯文本模式没有接入故障转移。 failoverParams 只传入 agent 分支(useSendChatTurn.ts#L1426),text 分支未传入(useSendChatTurn.ts#L1492)。因此用户即使开启自动故障转移,executionMode=text 下请求仍会直接失败,与 PR 描述和 UI 预期不符。建议让 text runtime 也使用同一 failover 计划,并补回归测试。

  2. 主供应商已熔断时,maxSwitches 会超限。 providerFailover.ts#L313 固定按 attempts = maxSwitches + 1 计算,但主供应商在前面已被过滤掉,此时跳到首个 fallback 已经消耗一次切换。实测 maxSwitches=1 时仍会依次启动 fallback-1fallback-2,实际发生两次切换。建议将初始熔断跳过计入切换预算,并增加该场景测试。

验证情况:GitHub checks 全绿;PR 与最新 main 可无冲突合并;本地在“PR + 最新 main”合并态下 GUI/WebUI 构建通过,124 项定向测试通过,mirror 检查通过,Rust schema 3 项测试通过。

su-fen and others added 2 commits August 8, 2026 23:55
… budget

withProviderFailover sized the attempt window as maxSwitches + 1
regardless of which candidate the plan started on. When the primary's
breaker was already open, the plan began on the first fallback — a
switch that had effectively happened — yet the budget still allowed
maxSwitches more moves, so maxSwitches=1 could walk through two
fallbacks (two real switches).

Charge that initial skip: when attemptPlan[0] is not the primary, the
window shrinks by one, so budget semantics match the user-visible
number of provider changes. Covered by two new tests: the skip
exhausting a budget of 1, and a budget of 2 still reaching the second
fallback after the skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The failover plan built in useSendChatTurn was only handed to the agent
branch, so with executionMode=text an enabled failover queue was dead
config: the request failed on the primary provider without ever trying
the queue.

Wire the same per-turn plan into the text runtime:

- textOnlyRuntime.streamAssistantMessage accepts an optional failover
  param and wraps each attempt in withProviderFailover, mirroring the
  agent runner's per-round candidate wiring (lazy fallback proxy/model
  preparation, sticky winner across recovery turns, breaker-open
  candidates skipped by the wrapper).
- runTextConversationTurn threads the plan through, surfaces the
  "switching to X" status line during a switch, and forwards onSwitched
  so the winning fallback becomes the conversation's selection exactly
  like agent mode.
- useSendChatTurn passes the already-built failoverParams to the text
  branch too.

Regression tests drive streamAssistantMessage against a mocked
streamSimpleByApi: uncommitted provider fault fails over to the queued
provider (discarded attempt invisible, callbacks fired), committed
content and client-class errors never switch, and the no-failover path
is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

供应商自动故障转移:设置无法持久化,且队列应为供应商级(cc-switch 语义)

3 participants