Skip to content

feat(opencode): killswitch — block requests when quota drops below threshold#35

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/killswitch
Open

feat(opencode): killswitch — block requests when quota drops below threshold#35
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/killswitch

Conversation

@iceteaSA
Copy link
Copy Markdown
Contributor

@iceteaSA iceteaSA commented May 21, 2026

Rebased onto upstream/main (v1.5.0) now that #34 (QuotaManager) is merged — this branch no longer depends on feat/quota-manager.

Per-account request blocking when remaining quota drops below configurable thresholds. Returns a synthetic 429 only when the request genuinely cannot be routed anywhere.

Features:

  • Per-account threshold overrides (5h and 7d windows), persisted to the sidecar killswitch block.
  • Eager quota refresh on the first request, with a fresh re-read before evaluation so the killswitch fires correctly even on the very first request (the pre-refresh snapshot is null).
  • Skip-main routing when main is killed (try surviving fallbacks). A synthetic 429 is returned only when main is killed and every fallback is killed or the request body is non-replayable (so it cannot be routed to a fallback). A request that is merely below the soft routing threshold still falls through to main.
  • killswitchPassesPolicy evaluates all present quota windows — a present below-threshold window still blocks even when the other window is missing (failClosed=false).
  • All killswitch / fallback decisions and the Retry-After computation read the fresh, token-aware QuotaManager cache, never the stale request-start storage snapshot.
  • Retry-After header set to the earliest quota reset across the unroutable accounts.
  • /claude-killswitch slash command (on / off / set / status) for runtime management.

Files:

  • packages/core/src/killswitch.ts — new
  • packages/core/src/accounts.ts — killswitch types and policy functions (shared DEFAULT_KILLSWITCH_THRESHOLDS)
  • packages/opencode/src/index.ts — killswitch fetch-gate + command registration
  • packages/opencode/src/tests/killswitch.test.ts + index.test.ts fetch-gate tests
  • README.md + packages/opencode/README.md — killswitch documentation

Summary by cubic

Adds a per‑account killswitch and a unified quota cache in @cortexkit/anthropic-auth-core so OpenCode blocks low‑quota requests and returns 429 with Retry‑After when unroutable or when the quota API is backed off. Adds /claude-killswitch, enforces the killswitch across all routing paths, and keeps the sidebar correct after reroutes and background updates.

  • New Features

    • Killswitch: per‑account 5h/7d thresholds, eager refresh of main+fallbacks with post‑refresh re‑read, token‑aware quota reads, skip main when killed, and synthetic 429 with Retry‑After (earliest reset + 60s) when no routable accounts remain or the body is non‑replayable; unified fallback filtering via getRoutableFallbackAccounts; respects failClosedOnUnknownQuota; optional quota.refreshEveryNRequests.
    • Command: /claude-killswitch on|off|set|status with per‑account thresholds, persisted to the sidecar killswitch block; READMEs document config and usage.
    • Sidebar: auto‑refresh on fallback storage changes and correct the active account after killswitch reroutes (no request needed).
  • Bug Fixes

    • 429 only when main is killed and no routable fallbacks exist or the body is non‑replayable; routing thresholds no longer hard‑block.
    • Fallback‑first and reactive filters never serve from a killswitch‑killed account; decisions use the fresh manager cache.
    • Fail‑closed + backed‑off quota API returns 429 with accurate Retry‑After and clearer messages; token‑aware fail‑closed read prevents cross‑account cache bleed; removed a shadowed request counter that broke every‑N refresh cadence.

Written for commit dc6650b. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR adds a per-account killswitch to the OpenCode plugin that hard-blocks requests when remaining quota drops below configurable thresholds, returning a synthetic 429 with Retry-After when no routable accounts remain. It also wires the killswitch consistently across all routing paths (fallback-first, soft-quota skip-main, reactive retries) via the shared getRoutableFallbackAccounts helper, and adds a /claude-killswitch slash command with disk persistence.

  • packages/core/src/accounts.ts: Adds killswitchPassesPolicy (correctly continues past missing quota windows instead of short-circuiting), killswitchRetryAfterSeconds, threshold normalisation with 5h/1w aliases, and setKillswitchPersistent.
  • packages/opencode/src/index.ts: Inserts the killswitch fetch-gate after main-token refresh; performs an eager quota refresh when needsRefresh, re-reads mainQuota from the quota-manager cache post-refresh, then either routes to surviving killswitch-filtered fallbacks or emits a hard-block 429. All fallback-selection paths now go through getRoutableFallbackAccounts so the killswitch is enforced uniformly.
  • Tests: Seven integration scenarios cover the key interactions — soft-threshold passthrough, non-replayable 429, fail-closed first-request block, sidebar state correction — and a dedicated unit-test file covers killswitchPassesPolicy, killswitchRetryAfterSeconds, and command parsing.

Confidence Score: 4/5

Safe to merge with the two P2 notes considered; the killswitch logic is sound and the core routing invariants are maintained.

The killswitch gate, killswitchPassesPolicy fix, and unified getRoutableFallbackAccounts helper are all well-implemented and tested. Two non-blocking concerns remain: the Retry-After header for non-replayable 429s can be shorter than expected when healthy-fallback reset times are included in the minimum, and a transient quota-API failure on the first request produces a misleading 'no routable accounts' message before backoff is armed. Neither causes incorrect routing or data loss, but both affect operator diagnostics and client retry behaviour.

packages/opencode/src/index.ts — the 429 response construction at the killswitch hard-block path (Retry-After computation and error message when quota is unknown due to API failure rather than threshold breach).

Important Files Changed

Filename Overview
packages/core/src/killswitch.ts New file implementing /claude-killswitch command parsing, status rendering, and executeKillswitchCommand. The set all handler assigns the same thresholds object to updated.main and every per-account entry (already flagged in a previous review comment).
packages/core/src/accounts.ts Adds KillswitchThresholds/KillswitchConfig types, killswitchPassesPolicy (correctly uses continue for missing windows), killswitchRetryAfterSeconds, and persistence helpers. Policy logic looks correct after the sawUnknownWindow fix.
packages/opencode/src/index.ts Adds the killswitch fetch-gate (eager refresh, mainQuota re-read, killswitch evaluation, fallback routing with hard-block 429), getRoutableFallbackAccounts, and reactive-fallback killswitch filtering. The gate is placed correctly before sendWithAccessToken. One UX concern: when the quota API transiently fails (not yet backed off) the 429 message reads 'Killswitch: no routable accounts' rather than indicating a quota-API problem.
packages/opencode/src/tests/killswitch.test.ts Comprehensive unit tests for killswitchPassesPolicy, killswitchRetryAfterSeconds, config helpers, persistence, and executeKillswitchCommand. Good coverage of edge cases including missing windows with fail-closed/open behaviour.
packages/opencode/src/tests/index.test.ts Adds seven integration tests covering routing scenarios: soft-threshold passthrough, non-replayable 429, fallback-first killswitch filtering, fail-closed first-request block, sidebar correction, and hard-block-over-fallback-error surface. Well-targeted scenarios.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Plugin as OpenCode Plugin
    participant QM as QuotaManager
    participant Main as Main Account
    participant FB as Fallback Accounts

    Client->>Plugin: fetch(request)
    Plugin->>Plugin: loadAccounts() / getAuth()

    alt fallback-first routing mode
        Plugin->>QM: getRoutableFallbackAccounts()
        QM-->>Plugin: killswitch-filtered fallbacks
        Plugin->>FB: tryUsableFallbackAccounts()
        FB-->>Plugin: response (if any)
        Plugin-->>Client: response (early return)
    end

    Plugin->>Plugin: refresh main access token (if expired)

    alt "failClosedOnUnknownQuota && !mainQuota && isBackedOff()"
        Plugin-->>Client: 429 Quota API unavailable
    end

    alt killswitch enabled
        Plugin->>QM: needsRefresh?
        opt needs refresh
            Plugin->>QM: refreshMain() + refreshAllFallbacks()
        end
        Plugin->>QM: "getMain(token).quota -> mainQuota (re-read)"
    end

    alt "killswitch enabled && !killswitchPassesPolicy(mainQuota)"
        Plugin->>QM: getRoutableFallbackAccounts()
        QM-->>Plugin: surviving fallbacks

        alt "replayable body && surviving fallbacks exist"
            Plugin->>FB: tryUsableFallbackAccounts(survivingFallbacks)
            FB-->>Plugin: fallbackResponse
            Plugin-->>Client: fallbackResponse (hard-block, no main fallthrough)
        else no survivors or non-replayable
            Plugin->>Plugin: killswitchRetryAfterSeconds()
            Plugin-->>Client: 429 + Retry-After
        end
    end

    Plugin->>Main: sendWithAccessToken()
    Main-->>Plugin: mainResponse

    alt mainResponse triggers fallback
        Plugin->>FB: tryFallbackAccounts() with killswitch filter
        FB-->>Plugin: fallbackResponse
    end

    Plugin-->>Client: final response
Loading

Comments Outside Diff (1)

  1. packages/core/src/killswitch.ts, line 340-355 (link)

    P2 Shared thresholds object reference when set all is used

    When entry.account === 'all', the same thresholds object is assigned to updated.main and to every entry in accounts[id]. All these properties point to the same object in memory. Callers that receive updatedConfig and subsequently mutate threshold values (e.g., patching a single window in-place) would silently affect every account and the main entry simultaneously. Constructing a fresh object for each assignment ({ ...thresholds }) avoids the aliasing.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (13): Last reviewed commit: "feat(opencode): killswitch — block reque..." | Re-trigger Greptile

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 10 files

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Fix all with cubic | Re-trigger cubic

Comment thread packages/opencode/src/index.ts Outdated
Comment thread packages/core/src/killswitch.ts Outdated
@iceteaSA iceteaSA force-pushed the feat/killswitch branch 4 times, most recently from d89dd9d to f667649 Compare May 22, 2026 17:08
@iceteaSA iceteaSA force-pushed the feat/killswitch branch 8 times, most recently from 0054553 to d125adb Compare June 3, 2026 18:21
Comment thread packages/opencode/src/index.ts Outdated
Comment thread packages/opencode/src/index.ts Outdated
Comment thread packages/core/src/accounts.ts
@iceteaSA iceteaSA force-pushed the feat/killswitch branch 2 times, most recently from 9092526 to 37961f1 Compare June 3, 2026 19:21
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 3, 2026

Want your agent to iterate on Greptile's feedback? Try greploops.

Comment thread packages/opencode/src/index.ts
@iceteaSA iceteaSA force-pushed the feat/killswitch branch 6 times, most recently from 20f6330 to ecf1511 Compare June 3, 2026 21:01
@iceteaSA
Copy link
Copy Markdown
Contributor Author

iceteaSA commented Jun 4, 2026

Updated: this branch now carries the sidebar quota fix (#57), merged in to keep the sidebar-touching branches consistent. #57's commits appear in this PR's diff until #57 merges to main, after which the diff reduces to this PR's own changes. Merge order: #57 first.

…reshold

Self-review fixes folded in:
- Token-aware fail-closed read: const mainQuota = quotaManager.getMain(auth.access)
  so a previous main account's cached quota can't satisfy the fail-closed check
  or feed the killswitch eval after a main-account switch.
- Removed a stray inner 'let sessionRequestCount = 0' + unconditional increment
  that shadowed the process-scoped counter, which had left the active-route
  fallback every-N refresh reading a never-incremented counter.
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.

1 participant