유대선
프로젝트로
·결정·4

Multi-vendor LLM failover: Gemini 429 auto-swap to Claude

Wrap two LLM dispatchers so a 429 from primary (Gemini) auto-swaps to fallback (Claude) inside the same call, keeping the caller unaware of vendors.

Context & constraints

Probe B against workflow w0dkpqbs2 confirmed Gemini 2.5 Flash free tier caps at 20 calls/day. After hitting it, every call returns 429 until the daily reset (PT midnight = 16:00 KST). Retry bursts inside the existing GeminiDispatcher do not help — the quota is already gone for the day.

Constraints visible in the diff:

Goals (ranked, inferred from commit context)

  1. Zero-touch unblock when Gemini quota is exhausted (user keeps hitting ⌥+Space, swap happens silently).
  2. Don't paper over real bugs — non-429 errors must still surface (primary code path bugs, network errors, decoding failures).
  3. Keep LLMDispatcher protocol stable; no changes to AnalyzeCoordinator or the HUD.
  4. Pass Swift 6 strict concurrency without @unchecked Sendable escape hatches.

Options considered

Options surfaced from the diff (DECISIONS.md Phase 7.2 entry):

Adjacent decision in the same commit for nested JSON tool schema:

Trade-off accepted (inferred)

Decision criteria to flip

Reverse this design if:

Success measure (or "not measured at the time")

Not measured at the time. Commit reports swift build 2.50s, swift test 116/116 pass (+11 new) and the planned next-step is dogfooding: "지금 ⌥+Space → Gemini 429면 자동 Claude swap. log에 [fallback] gemini → claude swap (primary 429/quota) 박힘." No production telemetry on swap frequency or fallback latency was added.

Reversal plan

DECISIONS.md sets the cost at ~15 minutes: delete FallbackDispatcher.swift, collapse the 4-way switch in AppDelegate.swift back to a single GeminiDispatcher.fromEnvironment(). ClaudeDispatcher.swift can stay (still usable as a primary). JSONValue rollback is separate and harder (~30 min) because it spreads through ClaudeDispatcher request/response types.

Verified by (post-hoc — what later commits prove this works?)

Original rationale beyond what is captured in DECISIONS.md Phase 7.2: reconstructed from the diff.

Status — Accepted (backfilled)

Commit — 33fd37e