163 lines
7.3 KiB
Markdown
163 lines
7.3 KiB
Markdown
# Plan: make local account state authoritative
|
|
|
|
## Problem
|
|
|
|
Current `accounts.json` state is not acting like a real source of truth for `/token`:
|
|
|
|
- `active_account_id` is persisted but not actually used to drive selection
|
|
- `last_known_usage` is mostly treated as a sort hint, then immediately replaced by a live refresh
|
|
- `/token` live-refreshes every candidate it tries, so local limits are not truly trusted
|
|
- the persisted snapshot contains duplicate derived fields (`used_percent`, `remaining_percent`, `exhausted`) in addition to canonical window and flag data
|
|
- as a result, the file is hard to reason about: some fields look authoritative but are only decorative or transient
|
|
|
|
## Desired behavior
|
|
|
|
1. `accounts.json` is the source of truth for `/token` selection.
|
|
2. If `active_account_id` exists and local state says the active account is usable, `/token` must try that account first.
|
|
3. Only if local state says the active account is blocked or unusable should `/token` fall back to choosing another account.
|
|
4. Fallback selection can keep the current ranking approach: most-used eligible account first.
|
|
5. `/usage` should continue to refresh all accounts live.
|
|
6. Persisted usage snapshots should store canonical data only, without duplicated derived fields.
|
|
|
|
## Recommended approach
|
|
|
|
### 1. Split `/token` selection into local selection and live validation
|
|
|
|
In `src/gibby/manager.py`:
|
|
|
|
- Add `_find_active_account(state)` to resolve `state.active_account_id` to an `AccountRecord`
|
|
- Add `_is_locally_blocked(account, current)` to decide from local file state only whether an account can be tried
|
|
- Add `_build_selection_order(state, current)` that:
|
|
- returns the active account first if it exists and is not locally blocked
|
|
- otherwise falls back to the remaining eligible accounts sorted by current saved-usage ranking
|
|
- never duplicates the active account in the fallback list
|
|
|
|
`_is_locally_blocked()` should use only persisted local state:
|
|
|
|
- blocked if `cooldown_until > now`
|
|
- blocked if `last_known_usage` exists and local usage indicates exhaustion
|
|
- otherwise not blocked
|
|
|
|
This gives the exact behavior the user requested:
|
|
|
|
- active account is mandatory first choice when nothing local blocks it
|
|
- local file decides whether active is allowed before any network call
|
|
- live refresh remains only a validation step for the chosen candidate
|
|
|
|
### 2. Keep live refresh only for accounts actually attempted
|
|
|
|
In `src/gibby/manager.py`:
|
|
|
|
- keep the current live refresh path (`refresh_account_usage`) once an account has been selected for an attempt
|
|
- if active account passes local checks but fails live validation, persist the updated state and continue to the next candidate
|
|
- if active account is locally blocked, skip live refresh for it during that `/token` call
|
|
|
|
`/usage` stays as-is and continues refreshing all accounts live.
|
|
|
|
### 3. Clean up the persisted usage snapshot schema
|
|
|
|
In `src/gibby/models.py` and `src/gibby/store.py`:
|
|
|
|
- stop persisting derived snapshot fields:
|
|
- `used_percent`
|
|
- `remaining_percent`
|
|
- `exhausted`
|
|
- keep only canonical persisted snapshot data:
|
|
- `checked_at`
|
|
- `primary_window`
|
|
- `secondary_window`
|
|
- `limit_reached`
|
|
- `allowed`
|
|
|
|
Implementation direction:
|
|
|
|
- keep `UsageSnapshot` as the in-memory model for now, but derive:
|
|
- `used_percent`
|
|
- `remaining_percent`
|
|
- `exhausted`
|
|
from canonical fields when loading/parsing
|
|
- update `store._snapshot_to_dict()` to write only canonical fields
|
|
- update `store._snapshot_from_dict()` to reconstruct the full in-memory `UsageSnapshot` from canonical persisted fields
|
|
|
|
This keeps code churn smaller than a full model rewrite while making the file itself cleaner and more honest.
|
|
|
|
### 4. Keep cooldown as the persisted local block, but make local exhaustion matter too
|
|
|
|
Local selection should not depend on a fresh API round-trip.
|
|
|
|
For `/token`:
|
|
|
|
- `cooldown_until` remains the strongest persisted block
|
|
- if cooldown is clear but local `last_known_usage` still says exhausted, treat the account as locally blocked too
|
|
- only accounts that pass local checks are eligible to be attempted live
|
|
|
|
This changes current behavior in an important way:
|
|
|
|
- today, an account with expired or missing cooldown can still be live-refreshed even if local snapshot says exhausted
|
|
- after the change, local state truly gates the initial decision
|
|
|
|
### 5. Preserve existing fallback ranking for non-active accounts
|
|
|
|
After active account is rejected locally, keep the current fallback sort in `manager.py`:
|
|
|
|
- primary window used percent descending
|
|
- secondary window used percent descending
|
|
|
|
That avoids a larger policy change in this pass and isolates the refactor to "trust local state first".
|
|
|
|
## Files to modify
|
|
|
|
- `/home/wzray/AI/gibby/src/gibby/manager.py`
|
|
- respect `active_account_id`
|
|
- add local-only eligibility predicate
|
|
- change selection order to active-first-when-locally-usable
|
|
- `/home/wzray/AI/gibby/src/gibby/models.py`
|
|
- keep canonical usage derivation helpers centralized
|
|
- support reconstructing derived values from canonical fields
|
|
- `/home/wzray/AI/gibby/src/gibby/store.py`
|
|
- write canonical snapshot shape only
|
|
- read canonical snapshot shape into full in-memory model
|
|
- `/home/wzray/AI/gibby/src/gibby/account_ops.py`
|
|
- keep refresh path aligned with canonical snapshot handling
|
|
- reuse a local exhaustion predicate if helpful instead of duplicating logic
|
|
- `/home/wzray/AI/gibby/tests/test_core.py`
|
|
- add and update selection behavior tests
|
|
- `/home/wzray/AI/gibby/tests/test_account_ops.py`
|
|
- update snapshot persistence assumptions if needed
|
|
- `/home/wzray/AI/gibby/tests/test_app.py`
|
|
- adjust fixture shapes only if response expectations change
|
|
- `/home/wzray/AI/gibby/tests/test_refresh_limits.py`
|
|
- ensure live refresh still rewrites canonical local state correctly
|
|
- `/home/wzray/AI/gibby/tests/test_oauth_helper.py`
|
|
- ensure oauth helper stores canonical snapshot shape correctly
|
|
|
|
## Test plan
|
|
|
|
### Selection behavior
|
|
|
|
Add or update tests in `tests/test_core.py` for:
|
|
|
|
- active account is used first when locally allowed, even if another account has higher saved usage
|
|
- active account is skipped without live refresh when `cooldown_until` is still active
|
|
- active account is skipped without live refresh when local snapshot says exhausted
|
|
- active account passes local checks but fails live refresh, then fallback account is tried
|
|
- missing or stale `active_account_id` falls back cleanly to non-active selection logic
|
|
- fallback ordering still prefers higher saved primary usage and uses secondary as tie-breaker
|
|
|
|
### Snapshot/file behavior
|
|
|
|
Add or update tests to verify:
|
|
|
|
- `accounts.json` no longer writes `used_percent`, `remaining_percent`, or `exhausted`
|
|
- loading canonical persisted snapshots still reconstructs full in-memory `UsageSnapshot`
|
|
- `/usage`, `refresh_limits.py`, and `oauth_helper.py` still persist refreshed canonical state correctly
|
|
|
|
## Verification
|
|
|
|
- `uv run pytest -q tests/test_core.py tests/test_account_ops.py tests/test_app.py tests/test_refresh_limits.py tests/test_oauth_helper.py`
|
|
- inspect a real `accounts.json` after `/usage` or `refresh_limits.py` and confirm snapshot entries contain only canonical fields
|
|
- manually test `/token` with:
|
|
- a locally usable active account
|
|
- a locally blocked active account
|
|
- a dangling `active_account_id`
|
|
- verify that active account is not live-refreshed when local file state already blocks it
|