rdself flagged in #2116 that the per-failure-kind breaker cooldown lacks a
user-controlled switch and should default conservatively for reverse-proxy
/ CLI-backed providers where forwarded 429 metadata is unreliable. This
PR adds the toggle, the per-provider default policy, and surfaces it in
the Resilience settings UI.
Why
---
A provider routed through cliproxyapi / lm-studio / vllm / etc may produce
429 metadata that the upstream layer fabricated. Letting that drive
circuit-breaker cooldown duration is unsafe by default. Direct cloud
providers (openai, anthropic, groq, cerebras, mistral, gemini, etc.) are
the safe ones to trust.
What
----
- New helper src/shared/utils/providerHints.ts:
defaultUseUpstream429BreakerHints(providerId) returns false for the
UPSTREAM_PROXY_PROVIDERS / SELF_HOSTED_CHAT_PROVIDER_IDS / isLocalProvider
/ isClaudeCodeCompatibleProvider sets; true for everything else.
resolveUseUpstream429BreakerHints() picks user override when set,
otherwise the per-provider default.
- Schema: connectionCooldownProfileSchema gains
useUpstream429BreakerHints: z.boolean().nullable().optional().
null is the explicit unset sentinel.
- Settings layer: ConnectionCooldownProfileSettings adds
useUpstream429BreakerHints?: boolean. Normalize preserves undefined
(no toBoolean coercion) and treats null as "delete the key". The
unset state survives all partial-merge round-trips. JSON serialization
omits the key when undefined.
- API route: PATCH /api/resilience detects useUpstream429BreakerHints
transitions (stored override change in either oauth or apikey profile)
and calls resetAllCircuitBreakers() so the registry stops serving
cached options.
- UI: ConnectionCooldownCard renderProfile gains a tri-state \<select\> with
options Default (per provider) / Always on / Always off. Read-only
rendering mirrors. Save handler converts undefined → null before PATCH
so JSON.stringify does not drop the key.
- Three wire-up call sites pass cooldownByKind + classifyError to
getCircuitBreaker only when useHints === true:
* open-sse/services/accountFallback.ts (configureProviderBreaker)
* src/sse/handlers/chat.ts (~L516)
* src/sse/handlers/chatHelpers.ts (~L151)
- classify429.ts gains classify429FromError(err) adapter that maps the
common HTTP-error shapes (axios-style err.response.status, low-level
err.status, message fallback) to FailureKind so callers can use it
directly as the breaker's classifyError option.
Backwards compatibility
-----------------------
Default behaviour is byte-identical when neither schema field nor user
override is touched, since useUpstream429BreakerHints stays undefined and
the helper returns the same per-provider policy that the breaker would
have applied with cooldownByKind unset. Existing code paths that never
construct a breaker with this option see no change.
Tests
-----
39 tests pass across 4 unit suites:
- tests/unit/provider-hints.test.ts (6 tests): per-provider defaults
for cloud / cliproxyapi / self-hosted / claude-code prefix; user
override truth-table in both directions including the v1 regression
test where proxy-with-override-true must resolve to true.
- tests/unit/resilience-settings-upstream429-breaker.test.ts (7 tests):
defaults absent; explicit boolean stored; null sentinel deletes the
key; key absent from JSON after delete; partial-merge omitting key
leaves existing value; toBoolean coercion explicitly avoided;
mixed-provider round-trip preserves disjoint per-profile settings.
- tests/unit/classify429.test.ts (carried from #2116, still passes).
- tests/unit/circuit-breaker-failure-kind.test.ts (carried, still passes).
Iteration history (codex audits)
--------------------------------
This plan went through 5 codex review rounds:
- v1 → 5 concerns (default-vs-gate logic, naming, missed third call
site, accountFallback layer separation, compat surface scope)
- v2 → HIGH: normalization could swallow per-provider default; 4 minor
- v3 → HIGH: binary BooleanField could not represent the unset state
- v4 → HIGH: PATCH partial-merge semantics — omitted key ≠ unset
- v5 → APPROVED with the null sentinel pattern
Audit trail and grep output for getCircuitBreaker call sites are in
the PR description.
Open questions for the maintainer (see PR body)
-----------------------------------------------
1. Hard gate vs default for proxy/CLI providers (v5 ships default-off,
user can override).
2. Cooldown values when toggle on are hardcoded for v1.
3. Reset granularity: resetAllCircuitBreakers() is coarse-grained but
matches existing patterns; per-profile reset is a follow-up.
4. The 3 getCircuitBreaker-with-options call sites duplicate logic —
a separate DRY refactor PR is suggested.