feat(providers): cc-switch 式供应商自动故障转移(同厂商同模型) - #385
Conversation
…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>
|
PR governance checks passed. Awaiting human review. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
没啥希望通过, 仓库所有者表示希望更简洁的provider, 如有此需求推荐安装一个cc-switch |
|
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>
…pstream PR Stack-Cairn#385) - 修复 modelFailover 设置无法持久化的问题(接入 localStorage 通道) - cc-switch 语义供应商级故障转移:只换供应商不换模型,未激活跳过 - provider::model 维度熔断器(失败阈值→熔断→冷却→半开探测) - 流包装器在首个可见内容前缓冲,丢弃尝试对用户不可见 - 成功应答供应商粘性保持并写回对话选择
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>
|
审核结论:当前不建议直接合入,存在 1 个功能阻断。 阻断:WebUI 的
|
…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>
|
@su-fen 感谢细致的审核。你指出的阻断在你审核的 head(
麻烦基于最新 head 复审。 🤖 Generated with Claude Code |
|
审查结论:暂不建议直接合入,建议 Request changes。 发现两个需要先修复的行为问题:
验证情况:GitHub checks 全绿;PR 与最新 |
… 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>
Linked issue
Closes #384
Summary
修复自动故障转移设置无法持久化的 bug,并将故障转移重构为 cc-switch 语义的供应商级设计:
modelFailover此前未接入任何持久化通道,退出设置页重进即丢失。现持久化到桌面配置库新表model_failover_settings(settings_load_all读取、settings_save_model_failover写入),并加入 Gateway settings-sync 协议(payload 类型 / 字段白名单 / build / apply 四处),GUI 与 WebUI 共享同一份配置:桌面改动推快照给 WebUI,WebUI 改动经SettingsUpdate通道落桌面 SQLite 并回推。localStorage 仅保留一次性旧数据迁移回退。provider::model维度的熔断器(连续失败阈值 → 熔断 → 冷却 → 半开探测);错误分类器仅对供应商故障类错误(5xx/网络/鉴权/配额)转移,客户端请求类错误(如上下文超限)不转移;流包装器在首个可见内容前缓冲事件,丢弃的尝试对用户不可见;成功应答的供应商粘性保持并写回对话选择。{vendor}占位符多次出现时只替换首个的问题。Change scope
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.tsScreenshots / preview
供应商配置右上角设置按钮 →「高级设置」抽屉——供应商级故障转移队列:
实测转移过程:将 P1 供应商(RightCode sale)的 API key 置为无效后发起对话,状态栏显示「第 1 轮:RightCode(sale)· claude-fable-5 不可用,正在切换到 RightCode · claude-fable-5…」,随后由 P2 供应商以同一模型完成应答,对话选择自动切到应答的供应商。
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)modelFailover变更进buildGatewaySettingsSyncUpdatePayload();双端 build→apply round-trip(部分 payload 不覆盖现有配置);同步来的配置可被buildModelFailoverPlan()构造实际候选 — GUInormalization.test.mjs+ webweb-settings.test.mjs+provider-failover-plan.test.mjsnode scripts/check-mirror.mjs✅(115 文件)pnpm tauri dev启动桌面端,复现原 bug 路径(退出设置→重进),配置保留;旧数据自动迁移;实际断供触发转移成功(见截图说明)Pre-submit checklist
🤖 Generated with Claude Code