opencode-auth-load-balancer 0.2.1 → 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.
- package/dist/fetch.d.ts +1 -0
- package/dist/index.js +986 -886
- package/dist/providers/anthropic/constants.d.ts +9 -4
- package/dist/providers/anthropic/fallback.d.ts +3 -1
- package/dist/providers/openai/usage.d.ts +14 -8
- package/dist/providers/registry.d.ts +26 -0
- package/dist/providers/types.d.ts +10 -6
- package/dist/scheduler/select.d.ts +5 -2
- package/dist/status.d.ts +1 -1
- package/dist/types.d.ts +55 -1
- package/dist/usage-refresh.d.ts +34 -16
- package/dist/util.d.ts +4 -3
- package/package.json +8 -8
- package/tui/auth-load-balancer-tui.logic.ts +131 -8
- package/tui/auth-load-balancer-tui.view.tsx +254 -33
|
@@ -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
|
|
39
|
-
* weekly/5h cap
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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`
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
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
|
-
/**
|
|
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;
|
package/dist/usage-refresh.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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`),
|
|
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`)
|
|
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.
|
|
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.
|
|
44
|
+
"@opencode-ai/plugin": ">=1.17.18"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@opencode-ai/plugin": "^1.17.
|
|
48
|
-
"@opentui/core": "0.4.
|
|
49
|
-
"@opentui/solid": "0.4.
|
|
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.
|
|
51
|
+
"@types/node": "^26.1.1",
|
|
52
52
|
"eslint-plugin-devup": "^2.0.19",
|
|
53
53
|
"husky": "^9.1",
|
|
54
|
-
"oxlint": "1.
|
|
54
|
+
"oxlint": "1.73",
|
|
55
55
|
"solid-js": "1.9.14",
|
|
56
|
-
"typescript": "^
|
|
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<
|
|
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
|
-
//
|
|
311
|
-
// rounding can never disagree with the
|
|
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`
|
|
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
|
|
437
|
+
if (mins <= 120) return `${mins}m`
|
|
318
438
|
const hrs = Math.floor(mins / 60)
|
|
319
|
-
if (
|
|
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)
|
|
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),
|