Files
OmniRoute/tests
Hernan Inverso f7279b3e19 feat(resilience): add useUpstream429BreakerHints toggle per #2100 follow-up
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.
2026-05-10 16:27:12 -03:00
..