opencode-auth-load-balancer 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,10 +35,15 @@ export declare const DEFAULT_FAMILY_ORDER: readonly string[];
35
35
  /** 429 header naming which rate-limit window is the binding constraint. */
36
36
  export declare const REPRESENTATIVE_CLAIM_HEADER = "anthropic-ratelimit-unified-representative-claim";
37
37
  /**
38
- * `representative-claim` values that name a MODEL-TIER window (a per-model
39
- * weekly/5h cap, e.g. `seven_day_opus`, `seven_day_fable`) rather than an
40
- * account-wide one (bare `five_hour` / `seven_day`). The capture group is the
41
- * tier name — the key of `PoolAccount.modelCooldownsUntil`.
38
+ * `representative-claim` values that name a TIER-scoped window (a per-model
39
+ * weekly/5h cap OR the premium/overage bucket — e.g. `seven_day_opus`,
40
+ * `seven_day_fable`, `seven_day_overage_included`) rather than a bare
41
+ * account-wide one (`five_hour` / `seven_day`). Used ONLY as a GATE by
42
+ * `planReactiveFallback`; the captured suffix is NOT the cooldown key. Anthropic
43
+ * emits non-model-family suffixes (`overage_included`) that no request's model
44
+ * family ever equals, so keying on the suffix records a dead
45
+ * `modelCooldownsUntil` entry the proactive skip can never consult — the cooldown
46
+ * is keyed by the REQUEST's model family instead (see `planReactiveFallback`).
42
47
  */
43
48
  export declare const MODEL_TIER_CLAIM_RE: RegExp;
44
49
  /** 429 header with the unix-seconds reset time of the binding window. */
@@ -40,7 +40,9 @@ export declare function resolveFallbackSetting(): FallbackSetting;
40
40
  * on a cheap condition (a stored tier cooldown or a response header) BEFORE
41
41
  * this parse.
42
42
  */
43
- export declare function downgradeModel(body: string, models: readonly string[]): ModelFallback | null;
43
+ export declare function downgradeModel(body: string, models: readonly string[]): (ModelFallback & {
44
+ fromTier: string;
45
+ }) | null;
44
46
  /**
45
47
  * REACTIVE: on a rejected response, when the binding limit is a MODEL-TIER cap
46
48
  * (`representative-claim: seven_day_<tier>` / `five_hour_<tier>`) and the sent
@@ -1,16 +1,22 @@
1
1
  import type { PoolAccount, UsageSnapshot } from '../../types';
2
2
  /**
3
3
  * Parse usage from Codex response headers (x-codex-{primary,secondary}-*).
4
- * primary = ~5h window (hourly), secondary = weekly window. The parse itself
5
- * (null-short-circuit, "no capturedAt", "`{}` collapses to null") lives in the
6
- * shared `parseWindowPairHeaders` — only the header names and the percent
7
- * scale are Codex-specific.
4
+ * The historical default is primary = ~5h and secondary = weekly; when the
5
+ * primary-window-minutes header identifies a seven-day primary, swap the pair
6
+ * before using the shared parser (see `isWeeklyMinutes`). An absent/
7
+ * unparsable duration header keeps the historical default — `Number(null)` is
8
+ * NaN, and every comparison against NaN is false. The parse itself (null-short-circuit,
9
+ * "no capturedAt", "`{}` collapses to null") lives in the shared
10
+ * `parseWindowPairHeaders` — only the header names, the percent scale, and
11
+ * this role detection are Codex-specific.
8
12
  */
9
13
  export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
10
14
  /**
11
- * Poll GET /wham/usage for authoritative 5h (primary) + weekly (secondary)
12
- * utilization without consuming inference quota. Null on failure. Sends the Codex
13
- * CLI's `codex-cli` UA and the `chatgpt-account-id` (from the stored id or, as a
14
- * fallback, decoded from the access-token JWT) — required for team/workspace accounts.
15
+ * Poll GET /wham/usage for authoritative 5h + weekly utilization without
16
+ * consuming inference quota, assigning each returned window to its role BY
17
+ * DURATION (see `isWeeklyMinutes`) rather than by position. Null on failure.
18
+ * Sends the Codex CLI's `codex-cli` UA and the `chatgpt-account-id` (from the
19
+ * stored id or, as a fallback, decoded from the access-token JWT) — required
20
+ * for team/workspace accounts.
15
21
  */
16
22
  export declare function fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
@@ -0,0 +1,26 @@
1
+ import type { ProviderAdapter } from './types';
2
+ /**
3
+ * Every provider adapter this plugin ships, in one place.
4
+ *
5
+ * The per-provider plugin exports in `index.ts` each own exactly ONE adapter
6
+ * (opencode registers an auth hook per provider), but the provider-agnostic
7
+ * surfaces — the usage-endpoint refresh and the `auth_lb_status` dashboard —
8
+ * must reach ALL of them. Without a shared registry those call sites
9
+ * hand-listed the pair and drifted: the request path only ever refreshed the
10
+ * adapter whose fetch it belonged to, so a provider you did not actively send
11
+ * requests to (e.g. Codex while you work in Claude) had NO refresh cycle at
12
+ * all after its startup seed and its usage froze for the whole opencode
13
+ * process lifetime. Adding a third provider now means editing this array only.
14
+ *
15
+ * `readonly` so a consumer cannot mutate the shared list; iteration order is
16
+ * irrelevant (each account is matched to its own adapter by `providerID`).
17
+ */
18
+ export declare const ADAPTERS: readonly ProviderAdapter[];
19
+ /**
20
+ * The adapter that owns `providerID`, or undefined for a pool row whose
21
+ * provider this build does not know (a hand-edited file, or a row left behind
22
+ * by a newer version). A linear scan, not a Map: the registry has a handful of
23
+ * entries and this runs per account on the request hot path, where building a
24
+ * Map per call would cost more than the scan it replaces.
25
+ */
26
+ export declare function adapterFor(adapters: readonly ProviderAdapter[], providerID: string): ProviderAdapter | undefined;
@@ -48,12 +48,16 @@ export interface ModelFallback {
48
48
  }
49
49
  /**
50
50
  * Reactive-fallback plan returned from a rejected (429) response whose headers
51
- * indicate a MODEL-TIER cap (not an account-wide limit). `tier` names the
52
- * limited tier (e.g. "opus", "fable") and `resetAt` (epoch ms) is when it
53
- * recovers — persisted as `PoolAccount.modelCooldownsUntil[tier]` so subsequent
54
- * requests for that tier avoid the account without paying a rejected
55
- * round-trip. `fallback` is the downgraded body the fetch loop adopts once the
56
- * WHOLE pool proves limited for the tier.
51
+ * indicate a MODEL-TIER cap (not an account-wide limit). `tier` is the REQUEST's
52
+ * own model family (e.g. "opus", "fable") — the exact key the fetch loop's
53
+ * proactive skip reads as `requestModelTier(body)` — NOT the raw claim suffix
54
+ * (Anthropic emits non-family suffixes like `overage_included` that no request
55
+ * ever maps to; keying on them recorded a dead `modelCooldownsUntil` entry the
56
+ * skip could never consult). `resetAt` (epoch ms) is when the tier recovers —
57
+ * persisted as `PoolAccount.modelCooldownsUntil[tier]` so subsequent requests
58
+ * for that tier avoid the account without paying a rejected round-trip.
59
+ * `fallback` is the downgraded body the fetch loop adopts once the WHOLE pool
60
+ * proves limited for the tier.
57
61
  */
58
62
  export interface ReactiveModelFallback {
59
63
  tier: string;
@@ -13,13 +13,16 @@ export interface Selection {
13
13
  * 1. Restrict to the provider's non-disabled accounts, minus `exclude` (already
14
14
  * tried this request).
15
15
  * 2. Prefer the subset that is currently available (not exhausted / cooling down).
16
- * 3. Among those, pick the highest urgency score.
16
+ * 3. Among those, pick the highest urgency score — but KEEP the incumbent
17
+ * (`incumbentId`, the provider's current in-use account) when its score is
18
+ * within `INCUMBENT_HYSTERESIS` of the best, so near-equal scores don't churn
19
+ * the account and waste prompt cache.
17
20
  * 4. If none are available, fall back to the least-bad account (lowest weekly
18
21
  * utilization) and flag the selection as `degraded`.
19
22
  *
20
23
  * Returns null when the provider has no usable account left.
21
24
  */
22
- export declare function selectAccount(accounts: PoolAccount[], providerID: string, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>): Selection | null;
25
+ export declare function selectAccount(accounts: PoolAccount[], providerID: string, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>, incumbentId?: string | null): Selection | null;
23
26
  /**
24
27
  * Session-aware selection. Balances prompt-cache stickiness against headroom safety
25
28
  * and quota perishability:
package/dist/status.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type SchedulerConfig } from './scheduler/config';
2
- import type { PoolFile } from './types';
2
+ import { type PoolFile } from './types';
3
3
  interface AccountStatus {
4
4
  id: string;
5
5
  label: string;
package/dist/types.d.ts CHANGED
@@ -78,17 +78,71 @@ export interface PoolAccount {
78
78
  * parse without a cast.
79
79
  */
80
80
  opusCooldownUntil?: number;
81
- /** Non-null when the account needs manual re-login (e.g. revoked refresh token). */
81
+ /**
82
+ * Non-null when the scheduler skips this account. Two sources, both gated by
83
+ * `isAvailable`: the automatic `invalid_grant: re-login required (…)` reason
84
+ * written on a revoked refresh token (`src/refresh.ts`), and the manual
85
+ * `MANUAL_DISABLED_REASON` sentinel written by the disable action (TUI menu /
86
+ * `auth_lb_disable` tool). The dashboards distinguish the two to render
87
+ * `disabled` (a user turned it off) vs `re-login` (needs a fresh OAuth login).
88
+ */
82
89
  disabledReason: string | null;
83
90
  }
91
+ /**
92
+ * Sentinel `disabledReason` written by the MANUAL disable action (the TUI
93
+ * sidebar menu and the `auth_lb_disable` tool), distinct from the automatic
94
+ * `invalid_grant: re-login required (…)` reason `src/refresh.ts` writes when a
95
+ * refresh token is revoked. Both are non-null so `isAvailable` skips the
96
+ * account; the value is kept distinct only so the dashboards can render
97
+ * `disabled` (a user turned it off) rather than `re-login` (needs a fresh login).
98
+ */
99
+ export declare const MANUAL_DISABLED_REASON = "manually disabled";
100
+ /**
101
+ * Re-login intents expire after ten minutes. A bounded lifetime prevents an
102
+ * abandoned TUI OAuth flow from redirecting a later, unrelated provider login
103
+ * onto the account row that happened to be clicked earlier.
104
+ */
105
+ export declare const RELOGIN_TTL_MS = 600000;
106
+ export interface ReloginIntent {
107
+ /** `PoolAccount.id` the freshly exchanged tokens must land on. */
108
+ accountId: string;
109
+ /** Provider the re-login was started for; an intent for another provider is ignored. */
110
+ providerID: string;
111
+ /** epoch ms after which the intent is stale and dropped at the read boundary. */
112
+ expiresAt: number;
113
+ }
84
114
  /** A conversation's sticky account assignment (preserves prompt cache across turns). */
85
115
  export interface SessionAssignment {
86
116
  accountId: string;
87
117
  updatedAt: number;
118
+ /**
119
+ * Set when THIS session's most recent successful request was served on an
120
+ * auto-downgraded fallback model (the requested model tier's weekly cap is
121
+ * exhausted pool-wide, so the request descended the fallback ladder). Read by
122
+ * the TUI bottom bar to PERSISTENTLY surface the degrade — the `onModelFallback`
123
+ * toast is transient and gone by the next turn, yet the session keeps running
124
+ * on the fallback model. `from`/`to` are the raw model ids (original requested
125
+ * model → served model), matching the toast wording. The whole session row is
126
+ * overwritten on every success, so the next request served on the requested
127
+ * model naturally clears this back to `undefined`.
128
+ */
129
+ fallback?: {
130
+ from: string;
131
+ to: string;
132
+ };
88
133
  }
89
134
  export interface PoolFile {
90
135
  version: 1;
91
136
  accounts: PoolAccount[];
137
+ /**
138
+ * Short-lived TUI → server handshake consumed by the first `addAccount` for
139
+ * this provider. It identifies the clicked pool row when a provider rotates
140
+ * refresh tokens and exposes no stable account id (Anthropic); without it a
141
+ * completed re-login appends a duplicate while leaving the revoked row
142
+ * behind. Absent in the steady state so `JSON.stringify` keeps the pool file
143
+ * compact.
144
+ */
145
+ relogin?: ReloginIntent;
92
146
  /**
93
147
  * providerID -> account that most recently served a request. Drives the ▶
94
148
  * "in use" marker in the status tool/CLI and the TUI bottom bar/sidebar;
@@ -1,5 +1,17 @@
1
1
  import type { ProviderAdapter } from './providers/types';
2
2
  import type { PoolFile } from './types';
3
+ /**
4
+ * Max lifetime of a TRANSIENT (non-quota) cooldown. fetch.ts cools a thrown
5
+ * network error or a header-less 429/402 for at most `ACCOUNT_COOLDOWN_MS`
6
+ * (5min) and an auth 401/403 for `AUTH_COOLDOWN_MS` (2min); ONLY a quota 429's
7
+ * `Retry-After` writes a cooldown beyond this. So a cooldown whose remainder
8
+ * still exceeds this bound is necessarily quota-derived — the one class that can
9
+ * outlive its own usage window after an out-of-band reset (see the stale-cooldown
10
+ * reconciliation below). Kept `=== ACCOUNT_COOLDOWN_MS` by a drift-guard test;
11
+ * defined locally because importing it from fetch.ts (which imports THIS module)
12
+ * would form a cycle.
13
+ */
14
+ export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
3
15
  /**
4
16
  * Test-only window into the module-private `lastPoll` throttle map. Never
5
17
  * imported by production code — only by usage-refresh tests that need to
@@ -9,22 +21,28 @@ import type { PoolFile } from './types';
9
21
  */
10
22
  export declare function _lastPollIdsForTests(): Set<string>;
11
23
  /**
12
- * Best-effort: seed/refresh usage for accounts whose WEEKLY snapshot is missing or
13
- * stale (`capturedAt` is stamped only when a weekly window arrives — see
14
- * `applyUsagePartial` in fetch.ts), via the provider's dedicated usage endpoint.
15
- * Throttled per account (at most one poll per account per SEED_TTL_MS). Callers
16
- * invoke it fire-and-forget, so it adds no latency to the request path; failures
17
- * are swallowed — response headers remain the primary, always-fresh usage signal
18
- * and this fixes cold-start blindness AND out-of-band server-side resets (e.g. a
19
- * promotional weekly-quota reset) that response headers alone don't converge.
20
- *
21
- * Returns a promise (for tests / explicit awaiting); request-path callers ignore it.
24
+ * Refresh stale usage for ONE provider's accounts (see `refreshUsage`).
22
25
  *
23
- * `poolSnapshot` lets the request hot path reuse the pool it JUST read for
24
- * account selection instead of paying a second serialized file read +
25
- * JSON.parse per request. The snapshot is only consulted for the staleness
26
- * gates below; the actual usage write still goes through `mutatePool` (which
27
- * re-reads under the lock), so a slightly stale snapshot is harmless. Callers
28
- * without a pool in hand (e.g. the startup seed in index.ts) omit it.
26
+ * Used by the flows that are inherently scoped to a single provider: the
27
+ * OAuth login callback (only the just-added account matters) and each
28
+ * provider plugin's startup seed, which awaits ITS provider's usage before
29
+ * priming that provider's in-use marker. Ambient callers must NOT use this —
30
+ * see `refreshAllUsageInBackground`.
29
31
  */
30
32
  export declare function refreshUsageInBackground(adapter: ProviderAdapter, now: number, poolSnapshot?: PoolFile): Promise<void>;
33
+ /**
34
+ * Refresh stale usage across EVERY registered provider (see `refreshUsage`).
35
+ *
36
+ * This is what the ambient callers — the request hot path and the
37
+ * `auth_lb_status` dashboard — use, and the distinction is load-bearing:
38
+ * usage converges to server-side truth from response headers (only for the
39
+ * provider you are actually sending requests to) or from a usage-endpoint
40
+ * poll. Scoping that poll to the requesting provider left an IDLE provider
41
+ * with no refresh cycle whatsoever after its one startup seed, so working all
42
+ * day in Claude froze the Codex numbers in the dashboard/TUI at whatever they
43
+ * were when opencode launched (and vice versa). Refreshing the whole registry
44
+ * instead costs nothing in the steady state — the per-account staleness gate
45
+ * and `lastPoll` throttle inside `refreshUsage` are unchanged, so an idle
46
+ * provider is polled at most once per SEED_TTL_MS and a fresh one not at all.
47
+ */
48
+ export declare function refreshAllUsageInBackground(now: number, poolSnapshot?: PoolFile): Promise<void>;
package/dist/util.d.ts CHANGED
@@ -30,11 +30,12 @@ export declare function clamp01(n: number): number;
30
30
  export declare function isFiniteNumber(v: unknown): v is number;
31
31
  /**
32
32
  * True for a plain JSON object (non-null `object`, not an array). Shared
33
- * trust-boundary predicate for the two consumers that accept arbitrary JSON
33
+ * trust-boundary predicate for the consumers that accept arbitrary JSON
34
34
  * shapes: the user-editable pool file's `lastSelected` / `sessions`
35
- * containers and session rows (`pool/store.ts`), and the incoming `system`
35
+ * containers and session rows (`pool/store.ts`), the incoming `system`
36
36
  * prompt blocks in the Anthropic request transform
37
- * (`providers/anthropic/transform.ts`). NOTE:
37
+ * (`providers/anthropic/transform.ts`), and the Codex `/wham/usage`
38
+ * `rate_limit` envelope (`providers/openai/usage.ts`). NOTE:
38
39
  * `tui/auth-load-balancer-tui.logic.ts` ships its OWN `isPlainRecordValue`
39
40
  * copy by design — the TUI runtime cannot import `src/`.
40
41
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts, weighted primarily by weekly usage with drain-before-reset scheduling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,19 +41,19 @@
41
41
  "prepare": "husky"
42
42
  },
43
43
  "peerDependencies": {
44
- "@opencode-ai/plugin": ">=1.17.13"
44
+ "@opencode-ai/plugin": ">=1.17.18"
45
45
  },
46
46
  "devDependencies": {
47
- "@opencode-ai/plugin": "^1.17.13",
48
- "@opentui/core": "0.4.2",
49
- "@opentui/solid": "0.4.2",
47
+ "@opencode-ai/plugin": "^1.17.18",
48
+ "@opentui/core": "0.4.3",
49
+ "@opentui/solid": "0.4.3",
50
50
  "@types/bun": "^1.3.14",
51
- "@types/node": "^26.1.0",
51
+ "@types/node": "^26.1.1",
52
52
  "eslint-plugin-devup": "^2.0.19",
53
53
  "husky": "^9.1",
54
- "oxlint": "1.72",
54
+ "oxlint": "1.73",
55
55
  "solid-js": "1.9.14",
56
- "typescript": "^6.0.3"
56
+ "typescript": "^7.0.2"
57
57
  },
58
58
  "keywords": [
59
59
  "opencode",
@@ -66,8 +66,12 @@ export interface PoolAccount {
66
66
  /** Loosely-typed view of the on-disk pool JSON (one shape for reads AND read-modify-writes). */
67
67
  export interface PoolShape {
68
68
  accounts?: PoolAccount[]
69
+ relogin?: { accountId?: string; providerID?: string; expiresAt?: number }
69
70
  lastSelected?: Record<string, string>
70
- sessions?: Record<string, { accountId?: string }>
71
+ sessions?: Record<
72
+ string,
73
+ { accountId?: string; fallback?: { from?: string; to?: string } }
74
+ >
71
75
  }
72
76
 
73
77
  /**
@@ -222,6 +226,62 @@ export function mutatePoolFile(
222
226
  }
223
227
  }
224
228
 
229
+ /**
230
+ * Sentinel `disabledReason` the manual disable toggle writes — the TUI copy of
231
+ * `src/types.ts`'s `MANUAL_DISABLED_REASON` (the TUI runtime cannot import
232
+ * `src/`; see the `isPlainRecordValue`/`isFiniteNumber` NOTEs above for the
233
+ * same trust boundary). Kept BYTE-IDENTICAL to the server constant so a manual
234
+ * disable written here reads back as `disabled` (not `re-login`) everywhere.
235
+ */
236
+ export const MANUAL_DISABLED_REASON = 'manually disabled'
237
+
238
+ /**
239
+ * NOTE: byte-identical copy of `src/types.ts`'s `RELOGIN_TTL_MS`, kept in sync
240
+ * by hand because the TUI runtime cannot import `src/`; see the
241
+ * `MANUAL_DISABLED_REASON` NOTE above for the same trust boundary.
242
+ */
243
+ export const RELOGIN_TTL_MS = 600_000
244
+
245
+ export function setReloginTargetInPool(
246
+ accountId: string,
247
+ providerID: string,
248
+ now: number,
249
+ path: string = POOL_FILE,
250
+ ): void {
251
+ mutatePoolFile((pool) => {
252
+ pool.relogin = {
253
+ accountId,
254
+ providerID,
255
+ expiresAt: now + RELOGIN_TTL_MS,
256
+ }
257
+ }, path)
258
+ }
259
+
260
+ export function clearReloginTargetInPool(path: string = POOL_FILE): void {
261
+ mutatePoolFile((pool) => {
262
+ delete pool.relogin
263
+ }, path)
264
+ }
265
+
266
+ /**
267
+ * Resolve the provider OAuth method by label instead of hardcoding an index:
268
+ * opencode aggregates auth methods from every plugin registered for a provider,
269
+ * so this plugin's position is not stable. Prefer its pooled-login label, then
270
+ * fall back to the provider's first OAuth method when no such label is present.
271
+ */
272
+ export function pickAuthMethodIndex(
273
+ methods: readonly { type?: string; label?: string }[] | undefined,
274
+ ): number | null {
275
+ const pooled = methods?.findIndex(
276
+ (method) =>
277
+ method.type === 'oauth' &&
278
+ method.label?.toLowerCase().includes('load balancer'),
279
+ )
280
+ if (pooled !== undefined && pooled >= 0) return pooled
281
+ const oauth = methods?.findIndex((method) => method.type === 'oauth')
282
+ return oauth !== undefined && oauth >= 0 ? oauth : null
283
+ }
284
+
225
285
  export function renameInPool(
226
286
  id: string,
227
287
  label: string,
@@ -248,6 +308,23 @@ export function deleteFromPool(id: string, path: string = POOL_FILE): void {
248
308
  }, path)
249
309
  }
250
310
 
311
+ /**
312
+ * Toggle an account's MANUAL disable flag: `disabled` writes the
313
+ * `MANUAL_DISABLED_REASON` sentinel (the scheduler then skips it, exactly like
314
+ * the server's `isAvailable`), `!disabled` clears it. Reversible, so — unlike
315
+ * Delete — the sidebar calls this straight through with no confirm dialog.
316
+ */
317
+ export function setDisabledInPool(
318
+ id: string,
319
+ disabled: boolean,
320
+ path: string = POOL_FILE,
321
+ ): void {
322
+ mutatePoolFile((pool) => {
323
+ const acct = (pool.accounts ?? []).find((a) => a.id === id)
324
+ if (acct) acct.disabledReason = disabled ? MANUAL_DISABLED_REASON : null
325
+ }, path)
326
+ }
327
+
251
328
  /**
252
329
  * Normalize a raw (loosely-typed) pool account into the strict shape the shared
253
330
  * scorer reads. The pool file is user-editable and read RAW here (the server
@@ -284,6 +361,48 @@ export function toScore(a: PoolAccount): ScoreAccount {
284
361
  }
285
362
  }
286
363
 
364
+ /**
365
+ * The account the CURRENT opencode session is pinned to for a provider — i.e.
366
+ * what THIS session is actually using — read from the pool's session map. The
367
+ * key is `<providerID>:s:<sessionID>`, exactly what `deriveSessionKey` +
368
+ * `recordSuccess` (src/fetch.ts) write on each served request. Returns
369
+ * `undefined` when there is no session (home screen) or the session has not yet
370
+ * pinned an account for this provider. The in-use (▶) marker uses this so it
371
+ * reflects the VIEWER's OWN session, not the global `lastSelected` (whichever
372
+ * session served most recently across the whole pool — misleading while several
373
+ * sessions run at once).
374
+ */
375
+ export function sessionAccountId(
376
+ pool: PoolShape,
377
+ providerID: string,
378
+ sessionId: string | undefined,
379
+ ): string | undefined {
380
+ if (!sessionId) return undefined
381
+ return pool.sessions?.[`${providerID}:s:${sessionId}`]?.accountId
382
+ }
383
+
384
+ /**
385
+ * The active model downgrade for THIS session on `providerID`, or `undefined`
386
+ * when the session's latest request ran on the model it asked for (or there is
387
+ * no session). `{ from, to }` are the raw model ids (requested → served) the
388
+ * server recorded in {@link SessionAssignment.fallback}. The bottom bar shows it
389
+ * so a session silently running on a fallback model is visible even after the
390
+ * transient switch toast is gone. Both fields are re-validated as strings (a
391
+ * hand-edited pool could hold anything) — a malformed `fallback` reads as "no
392
+ * downgrade" rather than rendering `undefined`.
393
+ */
394
+ export function sessionFallback(
395
+ pool: PoolShape,
396
+ providerID: string,
397
+ sessionId: string | undefined,
398
+ ): { from: string; to: string } | undefined {
399
+ if (!sessionId) return undefined
400
+ const fb = pool.sessions?.[`${providerID}:s:${sessionId}`]?.fallback
401
+ if (!fb || typeof fb.from !== 'string' || typeof fb.to !== 'string')
402
+ return undefined
403
+ return { from: fb.from, to: fb.to }
404
+ }
405
+
287
406
  /**
288
407
  * Byte-deterministic ASCII string comparator (never `localeCompare` — keeps
289
408
  * tier/provider ordering byte-identical regardless of host locale). Ships its
@@ -307,16 +426,17 @@ export function until(resetAt: number | undefined, now: number): string {
307
426
  if (!isFiniteNumber(resetAt) || resetAt <= now) return '-'
308
427
  // Mirror the server's `relTime` (src/status.ts) semantics: floor minutes at 1
309
428
  // (a sub-30s future reset must not render "0m" — '-' is reserved for elapsed)
310
- // and FLOOR the day figure (36h is "1d", not "2d") so the TUI bar's day/minute
311
- // rounding can never disagree with the CLI/tool dashboard's. NOTE: this is
429
+ // keep <=48h as hours (47h is "47h", not "1d") before FLOORing the day
430
+ // figure so the TUI bar's day/minute rounding can never disagree with the
431
+ // CLI/tool dashboard's. NOTE: this is
312
432
  // coarser than the CLI within the hour band on purpose (bar-width budget) —
313
- // the CLI renders `${hrs}h${mins % 60}m` (e.g. "3h25m"), this renders just
314
- // `${hrs}h` (e.g. "3h"). Same day-level bucket, less precision inside it —
433
+ // after the <=120m minute band, the CLI renders `${hrs}h${mins % 60}m`
434
+ // (e.g. "3h25m"), this renders just `${hrs}h` (e.g. "3h"). Same day-level bucket, less precision inside it —
315
435
  // not byte-identical output.
316
436
  const mins = Math.max(1, Math.round((resetAt - now) / 60_000))
317
- if (mins < 60) return `${mins}m`
437
+ if (mins <= 120) return `${mins}m`
318
438
  const hrs = Math.floor(mins / 60)
319
- if (hrs < 24) return `${hrs}h`
439
+ if (mins <= 48 * 60) return `${hrs}h`
320
440
  return `${Math.floor(hrs / 24)}d`
321
441
  }
322
442
  /** A window's utilization for the bar: "-" when absent, "0%" once it has reset (stale value dropped). */
@@ -351,7 +471,10 @@ export function stateOf(
351
471
  tiers: [string, number][],
352
472
  now: number,
353
473
  ): string {
354
- if (sa.disabledReason) return 're-login'
474
+ if (sa.disabledReason)
475
+ return sa.disabledReason === MANUAL_DISABLED_REASON
476
+ ? 'disabled'
477
+ : 're-login'
355
478
  if (sa.cooldownUntil > now) return `cooldown ${until(sa.cooldownUntil, now)}`
356
479
  if (isExhausted(sa, cfg, now)) return 'full'
357
480
  // A model-tier limit keeps the account usable (other models + downgrade),