-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(kiro): per-account quota display and quota-aware account pool #2875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5512269
docs(devlog): plan Kiro quota display and quota-aware account pool
lidge-jun 37c4cfd
feat(kiro): read per-account usage limits from Kiro's management endp…
lidge-jun be83fbb
feat(kiro): report per-account and provider-level Kiro quota
lidge-jun 67826ec
feat(oauth): rotate toward the account with known headroom on a 429
lidge-jun 829767c
feat(cli): show Kiro's monthly allowance and describe the pool honestly
lidge-jun 97c7335
docs(devlog): record the head-to-head result against kiro-lb
lidge-jun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
65 changes: 65 additions & 0 deletions
65
devlog/_plan/260829_kiro_quota_pool/000_research_problem_and_state.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # 000 — Kiro quota + pool: problem statement and current state | ||
|
|
||
| Unit: `devlog/_plan/260829_kiro_quota_pool/` | ||
| Opened: 2026-08-29 | ||
| Work classes: C3 (quota fetcher, pool selection), C2 (surfaces, docs) | ||
|
|
||
| ## The ask | ||
|
|
||
| Two capabilities, plus one reconciliation: | ||
|
|
||
| 1. **Quota display for Kiro.** Every other major OAuth provider in this proxy reports | ||
| remaining capacity; Kiro reports nothing. `rg -n -i kiro src/providers/quota.ts` | ||
| returns zero matches today. | ||
| 2. **Pool-based automatic loading.** Multiple Kiro accounts should load into a pool and | ||
| be selected automatically, preferring accounts that still have quota. | ||
| 3. **429 PR reconciliation.** Confirm which of the previously-submitted 429 failover PRs | ||
| actually landed on `dev`, and fold the landed behaviour into the Kiro path. | ||
|
|
||
| The comparison target is [minpeter/kiro-lb](https://github.com/minpeter/kiro-lb), an | ||
| AGPL-3.0 Python/FastAPI Kiro gateway with multi-account load balancing and an operations | ||
| dashboard. **We study its behaviour; we copy none of its code.** AGPL-3.0 is incompatible | ||
| with this repository's licensing, so every line here is written from the wire contract and | ||
| from our own existing seams. | ||
|
|
||
| ## What we already have (verified 2026-08-29 against origin/dev 124a2b148) | ||
|
|
||
| Kiro is further along than it looks: | ||
|
|
||
| - **Multi-account storage exists.** Kiro credentials live in the generic multiauth store | ||
| with `activeAccountId` plus an `accounts[]` array; each entry carries its own | ||
| `credential.kiro` routing metadata (`profileArn`, `ssoRegion`, `apiRegion`, | ||
| `clientId`, `clientSecret`). See `src/oauth/types.ts:14` and `src/oauth/store.ts:264`. | ||
| - **429 rotation already covers Kiro.** `isGenericFailoverProvider` excludes only | ||
| `openai` and `anthropic`, so any OAuth provider — Kiro included — rotates on a 429 | ||
| once two non-reauth accounts are present (`src/oauth/generic-account-failover.ts:44`, | ||
| `:81`, `:114`). | ||
| - **Rotation carries Kiro's routing metadata.** `applyFailoverSnapshot` reassigns | ||
| `parsed._kiroAuthContext` from the rotated snapshot, so a rotated bearer travels with | ||
| its own profile ARN and regions (`src/server/responses/core.ts:3063`). PR #2841 | ||
| (merged `5a829b7e9`) hardened exactly this class of bug for Copilot origins. | ||
| - **A per-account quota seam exists.** `supportsPerAccountQuota`, | ||
| `fetchProviderAccountQuotas`, the per-account TTL cache, generation reconciliation and | ||
| the GUI's `accounts[].quota` field are all built — but wired to Anthropic only | ||
| (`src/providers/quota.ts:1453`, `:1572`, `:1619`). | ||
|
|
||
| ## What is actually missing | ||
|
|
||
| | Gap | Evidence | | ||
| | --- | --- | | ||
| | No Kiro quota fetcher at all | `rg -i kiro src/providers/quota.ts` → 0 matches | | ||
| | `supportsPerAccountQuota("kiro")` is false, and a test locks it | `tests/provider-account-quota.test.ts:204` | | ||
| | Rotation is quota-blind: it walks stored order, skipping cooled accounts | `src/oauth/generic-account-failover.ts:157` | | ||
| | Rotation only reacts to a 429 it already suffered; a known-exhausted account is still tried first | same | | ||
| | CLI copy calls Kiro a "single login slot", contradicting the shipped multiauth add-account flow | `src/cli/account.ts:28`, `:215` | | ||
|
|
||
| That last row matters more than it reads: the feature exists and the product tells the | ||
| user it does not. | ||
|
|
||
| ## Non-goals for this unit | ||
|
|
||
| - No AGPL code, text, or structure copied from kiro-lb. | ||
| - No changes to the Codex or Anthropic pools; both are excluded from generic failover by | ||
| design and own their own affinity/probe semantics. | ||
| - No `src/lab/` involvement — the core boundary test forbids it. | ||
| - No release promotion to `main`. |
98 changes: 98 additions & 0 deletions
98
devlog/_plan/260829_kiro_quota_pool/001_research_upstream_wire_contract.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| # 001 — The Kiro usage-limits wire contract | ||
|
|
||
| Source of truth for this document: the observed Kiro CLI 2.19.x contract, cross-checked | ||
| against two independent third-party implementations. This operation is **undocumented** by | ||
| AWS; treat every field as best-effort and never fail a request because it changed. | ||
|
|
||
| ## The operation | ||
|
|
||
| ```http | ||
| POST /?origin=AI_EDITOR&isEmailRequired=true&profileArn=<optional> | ||
| Host: management.<region>.kiro.dev | ||
| Authorization: Bearer <access token> | ||
| Content-Type: application/x-amz-json-1.0 | ||
| Accept: application/json | ||
| x-amz-target: AmazonCodeWhispererService.GetUsageLimits | ||
| ``` | ||
|
|
||
| ```json | ||
| { "origin": "AI_EDITOR", "isEmailRequired": true, "profileArn": "<optional>" } | ||
| ``` | ||
|
|
||
| Two details that look like mistakes and are not: | ||
|
|
||
| - **The modeled arguments appear in both the query string and the JSON body.** That is the | ||
| observed CLI behaviour. Reproduce it rather than "simplifying" it; an AWS JSON-RPC | ||
| front door that also reads query parameters will accept both, and we have no way to test | ||
| which one it actually honours. | ||
| - **The host is `management.`, not `runtime.`** Generation goes to | ||
| `runtime.{region}.kiro.dev`; usage goes to a different subdomain. Our provider | ||
| `baseUrl` is the runtime host, so the quota fetcher must derive the management host | ||
| rather than reuse `baseUrl`. | ||
|
|
||
| ### Region resolution | ||
|
|
||
| The profile ARN is authoritative when present: `arn:aws:codewhisperer:<region>:<acct>:profile/<id>` | ||
| — take field 3. Otherwise fall back to the account's stored `apiRegion`, then `ssoRegion`, | ||
| then `us-east-1`. We already have all three on the credential | ||
| (`src/oauth/kiro-credentials.ts:285`) and a resolver in `src/oauth/kiro.ts:445`. | ||
|
|
||
| ## Response shape | ||
|
|
||
| ```json | ||
| { | ||
| "subscriptionInfo": { "subscriptionTitle": "KIRO PRO", "type": "Q_DEVELOPER_..." }, | ||
| "overageConfiguration": { "overageStatus": "ENABLED|DISABLED" }, | ||
| "usageBreakdownList": [ | ||
| { | ||
| "resourceType": "AGENTIC_REQUEST|CREDIT|...", | ||
| "currentUsageWithPrecision": 147.82, | ||
| "currentUsage": 147, | ||
| "usageLimitWithPrecision": 1000.0, | ||
| "usageLimit": 1000, | ||
| "currentOveragesWithPrecision": 0.0, | ||
| "overageRate": 0.04, | ||
| "unit": "CREDITS|INVOCATIONS", | ||
| "freeTrialInfo": { "freeTrialStatus": "ACTIVE", "usageLimitWithPrecision": 500.0 } | ||
| } | ||
| ], | ||
| "userInfo": { "email": "...", "userId": "..." }, | ||
| "nextDateReset": 1785542400.0, | ||
| "daysUntilReset": 3 | ||
| } | ||
| ``` | ||
|
|
||
| ### Field handling rules | ||
|
|
||
| 1. **Prefer `*WithPrecision`.** Kiro meters to 0.01 credit; the integer fields round | ||
| 695.17 down to 695. Fall back to the integer only when precision is absent. | ||
| 2. **Select the breakdown by `resourceType`, never by index.** Take `AGENTIC_REQUEST` | ||
| first, then `CREDIT`; if neither exists, report unknown rather than guessing. Taking | ||
| `[0]` means an upstream reorder silently reweights routing against an unrelated pool. | ||
| 3. **`currentUsage > usageLimit` is not necessarily exhaustion** when | ||
| `overageStatus` is `ENABLED` — enterprise accounts keep serving past the included | ||
| limit. Percent must clamp for display, but exhaustion must consult overage status. | ||
| 4. **`userInfo.email` is a personal identifier.** We request `isEmailRequired` because | ||
| the response shape is the observed contract, but the email must never be logged and | ||
| never persisted into quota state. Our account rows already carry a masked identity. | ||
| 5. **`freeTrialInfo` is a separate pool.** kiro-lb ignores it, which understates the | ||
| usable balance for trial users. We record it as its own window. | ||
|
|
||
| ## Cadence | ||
|
|
||
| Kiro's own pricing page says usage data refreshes "at least every 5 minutes", so polling | ||
| faster buys nothing. The existing provider cache TTL is 5 minutes | ||
| (`src/providers/quota.ts:37`) and the per-account TTL governs account rows; both are | ||
| already at or above the useful floor. No new timer is needed — the existing pull-on-demand | ||
| plus TTL is the right shape, and it means an idle proxy makes zero usage calls. | ||
|
|
||
| ## Auth-mode caveats | ||
|
|
||
| - **Enterprise / IdC accounts** carry a real profile ARN → send it. | ||
| - **AWS Builder ID** has no account-owned profile. We already resolve a *request-scoped* | ||
| service profile (`src/adapters/kiro-constants.ts:16`, applied at | ||
| `src/oauth/kiro.ts:508`) which must never be persisted as identity. For usage, send | ||
| the request-scoped value the same way the generation path does. | ||
| - **`ksk_` API keys** are not OAuth accounts, have no refresh identity, and there is no | ||
| evidence `GetUsageLimits` accepts the `tokentype: API_KEY` contract. Out of scope: | ||
| report unknown. |
95 changes: 95 additions & 0 deletions
95
devlog/_plan/260829_kiro_quota_pool/002_research_kirolb_headtohead.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # 002 — kiro-lb: what it does, and where it is beatable | ||
|
|
||
| Reference clone read read-only at `/tmp/kirolb.*/repo` (commit `474df2b` / `b2ec34d`, | ||
| 2026-08-26). AGPL-3.0. **Behaviour studied, no code reused.** | ||
|
|
||
| kiro-lb is a competent, purpose-built gateway. It is single-provider by design, and that | ||
| focus buys it a real dashboard and a working weighted router. An honest comparison has to | ||
| start by saying what it does well, because those are the bars we must clear. | ||
|
|
||
| ## What it does well | ||
|
|
||
| | Capability | Where | | ||
| | --- | --- | | ||
| | Reads real upstream usage per account | `kiro/usage.py:43-70` | | ||
| | Quota-weighted routing (exponential race, weight = remaining fraction) | `kiro/account_manager.py:1183-1208` | | ||
| | Distinct exclusion states with distinct timers | `kiro/account_manager.py:109-170` | | ||
| | Monthly-quota quarantine aligned to `nextDateReset` (6h floor, 32d cap) | `kiro/config.py:457-479` | | ||
| | Suspension (403) and credential-death (refresh 400/401) as separate states | `kiro/kiro_errors.py:30-51` | | ||
| | Persisted quota rows survive restart and seed routing | `kiro/store.py:206-289` | | ||
| | Cross-process refresh lease | `kiro/store.py:172-197` | | ||
|
|
||
| ## Where it is beatable — with citations | ||
|
|
||
| These are the gaps the reviewers verified in its source, not marketing points. | ||
|
|
||
| 1. **Weighted routing is model-blind.** `_select_account()` builds candidates from | ||
| `list(self._accounts)` and never consults its own `model` argument or | ||
| `_model_to_accounts` (`kiro/account_manager.py:1259-1276`). An account whose plan | ||
| cannot serve the requested model is discovered by *failing a request*. | ||
| 2. **Headroom is stale for up to the full poll interval.** Successful requests do not | ||
| decrement local headroom (`kiro/account_manager.py:1369-1432`); only a poll updates it | ||
| (default 900s, `kiro/config.py:533`). A hot account keeps its high weight for ~15 | ||
| minutes while it burns through its balance. | ||
| 3. **`USAGE_REFRESH_INTERVAL_SECONDS=0` does not disable polling.** The comment says it | ||
| does; the loop enforces `max(interval, 60)` and startup always polls | ||
| (`main.py:342-359`). Zero means *every minute*. | ||
| 4. **Bulk polling is sequential with a fresh 20s client per account.** `refresh_all_account_usage()` | ||
| awaits one at a time (`kiro/dashboard.py:806-821`) and `usage.py:64-70` constructs a new | ||
| `AsyncClient` per call. An N-account pool of dead accounts costs ~N × 20s per pass. | ||
| 5. **A concurrent account deletion can abort an entire refresh pass.** The loop re-indexes | ||
| `manager._accounts[account_id]` after an await, outside the lock | ||
| (`kiro/dashboard.py:806-815`) — a `KeyError` there ends the pass, so later accounts | ||
| never refresh. | ||
| 6. **Breakdown selection falls back to index 0.** If no `AGENTIC_REQUEST` entry exists, | ||
| the first entry becomes the routing signal (`kiro/usage.py:74-78`). An upstream | ||
| addition silently reweights the pool on an unrelated resource. | ||
| 7. **`freeTrialInfo` is dropped**, understating usable balance for trial accounts | ||
| (`kiro/usage.py:97-111`). | ||
| 8. **No absolute reset timestamp in the UI**, only a coarse relative duration, and only | ||
| for excluded accounts (`frontend/src/features/dashboard/quota-display.ts:18-33`). | ||
| `unit` and `overageRate` are fetched then discarded (`kiro/dashboard.py:613-627`). | ||
| 9. **Pool loading is not automatic discovery.** No standard cache path (`~/.aws/sso/cache`, | ||
| `~/.local/share/kiro-cli`) is scanned unless already registered as a source; scanning | ||
| happens at startup/handoff only, with no watcher (`kiro/account_manager.py:407-505`). | ||
| The README tells users to add accounts through dashboard device login. | ||
| 10. **Suspension/auth-death prose contradicts the code**: both expire automatically after | ||
| 24h (`kiro/config.py:481-494`) although comments claim only support or re-login clears | ||
| them. | ||
| 11. **Refresh-lease waiting has no deadline** — contenders poll every 50ms forever | ||
| (`kiro/auth.py:965-980`). | ||
| 12. **Device-login flows are process memory only** (`kiro/device_login.py:97-113`); a | ||
| restart mid-approval invalidates the login. | ||
| 13. **Dashboard copy is inaccurate**: it says "only the refresh token is stored" while | ||
| `internal_credentials()` persists access token, refresh token, expiry, region and | ||
| client secret (`kiro/device_login.py:341-366`). | ||
|
|
||
| ## What "better" must mean for us | ||
|
|
||
| Beating it is not "we also show a number". Our structural advantages have to be real, and | ||
| stated no wider than what we ship: | ||
|
|
||
| - **Quota-aware recovery ordering.** On a 429 we rotate toward the account with the most | ||
| known headroom instead of walking the roster blind. This is *recovery* ordering, not | ||
| pre-dispatch selection — see the scope note below. | ||
| - **Parallel, deadline-bounded probing** with per-account failure isolation. | ||
| - **Explicit unknown state** everywhere — never present a failed probe as "0% used". | ||
| - **No new background timer**: pull-on-demand plus TTL, so an idle proxy is silent. | ||
| - **Correct pool semantics we already own**: request-local rotation that never mutates the | ||
| operator's `activeAccountId`, and per-account routing metadata that travels with its | ||
| own bearer. | ||
|
|
||
| ## Scope honesty (amended after audit round 1) | ||
|
|
||
| Two advantages were claimed here and have been withdrawn, because the design could not | ||
| back them: | ||
|
|
||
| - **Pre-request, model-aware selection.** Our rotation hook runs only inside the 429 | ||
| branch (`src/server/responses/core.ts:5574`), so nothing in this unit chooses an | ||
| account *before* the first request, and no model is passed to the ranker. kiro-lb's | ||
| weighted router genuinely is pre-request (though it is model-blind). Deferred to a | ||
| follow-up work-phase; not claimed here. | ||
| - **Live decrement between polls.** `ProviderQuota` stores percent, not absolute | ||
| used/limit, and Kiro meters fractional credits — one turn is not one credit. Any local | ||
| decrement would be invented data. kiro-lb's 15-minute staleness gap is real; we do not | ||
| currently close it, we only poll on demand with a shorter TTL. |
46 changes: 46 additions & 0 deletions
46
devlog/_plan/260829_kiro_quota_pool/003_research_pr_reconciliation.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # 003 — 429 PR reconciliation (state as of 2026-08-29) | ||
|
|
||
| `origin/dev` and GitHub `dev` both at `124a2b1487996f8a8ebb2067b22c9e758fa6016f`. | ||
|
|
||
| "Landed" below means the *behaviour* is on `dev`. Squash merges do not preserve the PR | ||
| head SHA in ancestry, so head-containment is the wrong test for all but one of these. | ||
|
|
||
| | PR | Subject | State | Landed as | | ||
| | --- | --- | --- | --- | | ||
| | #2590 | generic multi-account 429 failover (#2568a) | MERGED | `816f3a159` | | ||
| | #2607 | rotate generic OAuth accounts on 429 in sidecars | MERGED | `87250870c` | | ||
| | #2608 | cursor adapter-event 429 rotation | MERGED | `6b508d5a8` | | ||
| | #2640 | activate failover on account presence (#2568d) | MERGED | `8bfac7146` | | ||
| | #2841 | bind a rotated OAuth bearer to its own Copilot origin | MERGED | `5a829b7e9` | | ||
| | #927 | compact: alternate account on pool 429/402 | MERGED | `87c479006` (head in ancestry) | | ||
| | #2573 | antigravity quota exhaustion spelling | MERGED | `bfe2cb5a1` | | ||
| | #2745 | rebind credential identity on every OAuth 429 rotation | CLOSED | superseded by #2807 → #2841 | | ||
| | #2807 | same, v2 | CLOSED | superseded by #2841 | | ||
|
|
||
| ## The nuance on #2745 / #2807 | ||
|
|
||
| Their *security outcome* landed; their *complete diff* did not. #2841 fixed all four | ||
| snapshot/origin read sites and added stronger coverage, but the broader refactor those PRs | ||
| proposed — relocating `sentOAuthSnapshot`, replay identity, and Cursor cleanup into | ||
| `applyFailoverSnapshot` — was not adopted. Neither branch needs rebasing; the accepted | ||
| requirement is represented on `dev`. | ||
|
|
||
| What this means for **this** unit: the credential/identity-pairing invariant is already | ||
| enforced for Copilot origins and for Kiro's `_kiroAuthContext` | ||
| (`src/server/responses/core.ts:3050-3063`). Our Kiro work must not regress it, and our | ||
| regression test must prove a rotated Kiro bearer never travels with another account's | ||
| profile ARN. | ||
|
|
||
| ## Still open and relevant | ||
|
|
||
| - **#2783** (quota-reset detection) — OPEN, CI green at its recorded head, but | ||
| `CONFLICTING`/`DIRTY` against current `dev`. It is a *different* unit (usage-window | ||
| reset detection and notification). Out of scope here; it needs its own rebase pass. | ||
|
|
||
| Not relevant: #2729, #1704, #150, #138 are closed and superseded or dormant. | ||
|
|
||
| ## Conclusion | ||
|
|
||
| There is no unmerged 429 work to land. The reconciliation answer is "all merged except two | ||
| that were deliberately superseded by #2841", and the follow-up action is to *preserve* that | ||
| invariant while adding Kiro quota, not to re-open old branches. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Scope the reconciliation conclusion to the 429 PRs.
#2783is markedOPENat Lines 36-38, but this conclusion says “all merged except two” without a scope qualifier. The next sentence limits the claim to unmerged 429 work, but the quoted reconciliation answer can still be read as contradicting the table. State that all 429-related PRs are merged, and state separately that#2783remains open but is out of scope.Proposed wording
📝 Committable suggestion
🤖 Prompt for AI Agents