opencode-auth-load-balancer 0.2.1
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/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/fetch.d.ts +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14829 -0
- package/dist/notify.d.ts +47 -0
- package/dist/pool/lock.d.ts +39 -0
- package/dist/pool/paths.d.ts +16 -0
- package/dist/pool/store.d.ts +51 -0
- package/dist/prime.d.ts +32 -0
- package/dist/providers/anthropic/adapter.d.ts +8 -0
- package/dist/providers/anthropic/cch.d.ts +20 -0
- package/dist/providers/anthropic/constants.d.ts +64 -0
- package/dist/providers/anthropic/fallback.d.ts +52 -0
- package/dist/providers/anthropic/oauth.d.ts +8 -0
- package/dist/providers/anthropic/transform.d.ts +23 -0
- package/dist/providers/anthropic/usage.d.ts +14 -0
- package/dist/providers/headers.d.ts +7 -0
- package/dist/providers/http-timeouts.d.ts +32 -0
- package/dist/providers/oauth-callback.d.ts +100 -0
- package/dist/providers/openai/adapter.d.ts +9 -0
- package/dist/providers/openai/constants.d.ts +18 -0
- package/dist/providers/openai/jwt.d.ts +5 -0
- package/dist/providers/openai/oauth.d.ts +12 -0
- package/dist/providers/openai/transform.d.ts +18 -0
- package/dist/providers/openai/usage.d.ts +16 -0
- package/dist/providers/pkce.d.ts +10 -0
- package/dist/providers/types.d.ts +122 -0
- package/dist/providers/usage-headers.d.ts +55 -0
- package/dist/providers/usage-http.d.ts +8 -0
- package/dist/refresh.d.ts +13 -0
- package/dist/scheduler/config.d.ts +46 -0
- package/dist/scheduler/score-core.d.ts +167 -0
- package/dist/scheduler/select.d.ts +41 -0
- package/dist/session.d.ts +15 -0
- package/dist/status.d.ts +55 -0
- package/dist/types.d.ts +101 -0
- package/dist/usage-merge.d.ts +23 -0
- package/dist/usage-refresh.d.ts +30 -0
- package/dist/util.d.ts +118 -0
- package/package.json +75 -0
- package/tui/auth-load-balancer-scoring.ts +344 -0
- package/tui/auth-load-balancer-tui.logic.ts +362 -0
- package/tui/auth-load-balancer-tui.ts +39 -0
- package/tui/auth-load-balancer-tui.view.tsx +327 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, non-JSX pool-file logic for the load balancer's TUI surfaces: resolving
|
|
3
|
+
* the pool-file path, reading/normalizing the on-disk pool file, the atomic
|
|
4
|
+
* rename/delete mutations reachable from the sidebar menu, and the
|
|
5
|
+
* display-formatting helpers (`pct`/`until`/`winPct`/`tierResets`/`stateOf`) both
|
|
6
|
+
* `app_bottom` and `sidebar_content` render with. Split out of
|
|
7
|
+
* `auth-load-balancer-tui.view.tsx` (which imports these unchanged) so this
|
|
8
|
+
* logic — which needs no SolidJS/@opentui/JSX at all — is directly
|
|
9
|
+
* unit-testable exactly like its server-side analogue, `src/pool/store.ts`,
|
|
10
|
+
* already is.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
displayUtil,
|
|
18
|
+
isExhausted,
|
|
19
|
+
loadScoreConfig,
|
|
20
|
+
type ScoreAccount,
|
|
21
|
+
type ScoreWindow,
|
|
22
|
+
} from './auth-load-balancer-scoring'
|
|
23
|
+
|
|
24
|
+
// Scoring knobs read from env exactly like the server — single-sourced in the shared
|
|
25
|
+
// ./auth-load-balancer-scoring module (a byte copy of src/scheduler/score-core.ts).
|
|
26
|
+
// Used by `stateOf` below (`isExhausted(sa, cfg, now)`) and re-exported so
|
|
27
|
+
// `auth-load-balancer-tui.view.tsx` (which already imports several other
|
|
28
|
+
// symbols from this module) shares the same computed value instead of
|
|
29
|
+
// calling `loadScoreConfig()` again for an identical result.
|
|
30
|
+
export const cfg = loadScoreConfig()
|
|
31
|
+
|
|
32
|
+
export function poolFile(): string {
|
|
33
|
+
const override = process.env.OPENCODE_AUTH_LB_DIR?.trim()
|
|
34
|
+
if (override) return join(override, 'auth-load-balancer.json')
|
|
35
|
+
const xdg = process.env.XDG_DATA_HOME?.trim()
|
|
36
|
+
if (xdg) return join(xdg, 'opencode', 'auth-load-balancer.json')
|
|
37
|
+
return join(
|
|
38
|
+
homedir(),
|
|
39
|
+
'.local',
|
|
40
|
+
'share',
|
|
41
|
+
'opencode',
|
|
42
|
+
'auth-load-balancer.json',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Captured ONCE at module load, like `cfg` above (and the server's cachedPool
|
|
47
|
+
// in src/pool/paths.ts) — the env-derived path never changes at runtime.
|
|
48
|
+
export const POOL_FILE = poolFile()
|
|
49
|
+
|
|
50
|
+
export interface UsageWindow {
|
|
51
|
+
utilization?: number
|
|
52
|
+
resetAt?: number
|
|
53
|
+
}
|
|
54
|
+
export interface PoolAccount {
|
|
55
|
+
id: string
|
|
56
|
+
providerID: string
|
|
57
|
+
label: string
|
|
58
|
+
usage?: { weekly?: UsageWindow | null; hourly?: UsageWindow | null } | null
|
|
59
|
+
cooldownUntil?: number
|
|
60
|
+
/** tier name → epoch ms until that model tier's cap resets (tier requests steer around the account until then). */
|
|
61
|
+
modelCooldownsUntil?: Record<string, number> | null
|
|
62
|
+
/** LEGACY pre-tier-map field (folded into `modelCooldownsUntil.opus` for display until the server migrates the file). */
|
|
63
|
+
opusCooldownUntil?: number
|
|
64
|
+
disabledReason?: string | null
|
|
65
|
+
}
|
|
66
|
+
/** Loosely-typed view of the on-disk pool JSON (one shape for reads AND read-modify-writes). */
|
|
67
|
+
export interface PoolShape {
|
|
68
|
+
accounts?: PoolAccount[]
|
|
69
|
+
lastSelected?: Record<string, string>
|
|
70
|
+
sessions?: Record<string, { accountId?: string }>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True for a plain JSON object (non-null `object`, not an array). Local copy
|
|
75
|
+
* of `isPlainObject` (src/util.ts) by design — the TUI runtime cannot import
|
|
76
|
+
* `src/` — under the name that file's NOTE cross-references. No `value is
|
|
77
|
+
* Record<…>` narrowing: the `.filter(isPlainRecordValue)` site below must
|
|
78
|
+
* keep its `PoolAccount[]` element type, which a type-guard predicate to a
|
|
79
|
+
* different type would widen away.
|
|
80
|
+
*/
|
|
81
|
+
export function isPlainRecordValue(value: unknown): boolean {
|
|
82
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize parsed pool JSON defensively. `JSON.parse('null')` SUCCEEDS (so
|
|
87
|
+
* readPool's catch never fires) and a hand-edited non-array `accounts` passes
|
|
88
|
+
* straight through — either then throws inside the BottomBar/SidebarPanel
|
|
89
|
+
* memos (`p.accounts ?? []` on null / `.find` on an object), breaking BOTH
|
|
90
|
+
* slots until the file is repaired by hand. The server guards these cases in
|
|
91
|
+
* `readRaw` (src/pool/store.ts); mirror that trust boundary here.
|
|
92
|
+
*/
|
|
93
|
+
export function toPoolShape(parsed: unknown): PoolShape {
|
|
94
|
+
if (!isPlainRecordValue(parsed)) return {}
|
|
95
|
+
const pool = parsed as PoolShape
|
|
96
|
+
// Row-level mirror of the server's `normalizeAccounts` trust boundary
|
|
97
|
+
// (store.ts): a hand-edited `accounts: [null, …]` (or a primitive element)
|
|
98
|
+
// otherwise throws inside the BottomBar/SidebarPanel memos (`x.id` on null)
|
|
99
|
+
// every poll until the file is repaired by hand — the server only heals it
|
|
100
|
+
// on its next write. Only the IDENTITY fields are irreparable:
|
|
101
|
+
// `normalizeAccounts` (src/pool/store.ts) drops rows with a non-string `id`
|
|
102
|
+
// OR `providerID` (they cannot be guessed), so drop exactly those here too.
|
|
103
|
+
// A non-string `label` is REPARABLE — `normalizeAccounts`' label heal sets
|
|
104
|
+
// it to `''` and KEEPS the row — so healing
|
|
105
|
+
// (not dropping) below keeps the bar/sidebar consistent with the server
|
|
106
|
+
// dashboards AND keeps `mutatePoolFile` (which re-serializes this shape)
|
|
107
|
+
// from permanently deleting an account the server would have healed. The
|
|
108
|
+
// inline arrow (not a type-guard predicate) keeps the `PoolAccount[]`
|
|
109
|
+
// element type — see the isPlainRecordValue note.
|
|
110
|
+
pool.accounts = Array.isArray(pool.accounts)
|
|
111
|
+
? pool.accounts.filter(
|
|
112
|
+
(row) =>
|
|
113
|
+
isPlainRecordValue(row) &&
|
|
114
|
+
typeof row.id === 'string' &&
|
|
115
|
+
typeof row.providerID === 'string',
|
|
116
|
+
)
|
|
117
|
+
: undefined
|
|
118
|
+
if (pool.accounts) {
|
|
119
|
+
for (const row of pool.accounts) {
|
|
120
|
+
// Mirror `normalizeAccounts`' label heal — an empty label renders
|
|
121
|
+
// blank but never throws.
|
|
122
|
+
if (typeof (row as { label?: unknown }).label !== 'string') row.label = ''
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// `lastSelected` / `sessions` must be plain records (the server heals these
|
|
126
|
+
// via `isPlainObject`). A hand-edited primitive (`"lastSelected": "oops"`)
|
|
127
|
+
// or array otherwise survives: `Object.entries` on a string iterates
|
|
128
|
+
// per-character garbage every poll, and `mutatePoolFile` writes the corrupt
|
|
129
|
+
// shape back. Reject to `undefined` so the `?? {}` fallbacks apply and the
|
|
130
|
+
// next TUI write heals the file.
|
|
131
|
+
if (!isAbsentOrPlainRecord(pool.lastSelected)) pool.lastSelected = undefined
|
|
132
|
+
if (!isAbsentOrPlainRecord(pool.sessions)) pool.sessions = undefined
|
|
133
|
+
return pool
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True when the value is absent (`undefined`) or a plain JSON object. NOT the
|
|
138
|
+
* same as `isPlainObject` (src/util.ts) — that returns false for `undefined`;
|
|
139
|
+
* here absent fields are fine because the `?? {}` fallbacks downstream heal them.
|
|
140
|
+
*/
|
|
141
|
+
export function isAbsentOrPlainRecord(value: unknown): boolean {
|
|
142
|
+
return value === undefined || isPlainRecordValue(value)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read the pool file at `path` (defaults to `POOL_FILE`); malformed/missing → `{}`. */
|
|
146
|
+
export function readPool(path: string = POOL_FILE): PoolShape {
|
|
147
|
+
try {
|
|
148
|
+
return toPoolShape(JSON.parse(readFileSync(path, 'utf8')))
|
|
149
|
+
} catch {
|
|
150
|
+
return {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Injectable sync fs surface — mirrors `src/pool/store.ts`'s async `FsOps` so
|
|
156
|
+
* `mutatePoolFile`'s atomic-write + cleanup-on-failure branches are
|
|
157
|
+
* unit-testable without racing the real filesystem. `BottomBar`/`SidebarPanel`
|
|
158
|
+
* never pass this — they get `realFsOps` via the default parameter.
|
|
159
|
+
*/
|
|
160
|
+
export interface FsOps {
|
|
161
|
+
readFileSync: (path: string, encoding: 'utf8') => string
|
|
162
|
+
writeFileSync: (path: string, data: string, options: { mode: number }) => void
|
|
163
|
+
renameSync: (oldPath: string, newPath: string) => void
|
|
164
|
+
unlinkSync: (path: string) => void
|
|
165
|
+
}
|
|
166
|
+
const realFsOps: FsOps = { readFileSync, writeFileSync, renameSync, unlinkSync }
|
|
167
|
+
|
|
168
|
+
// Mirrors src/pool/store.ts's `RENAME_RETRIES`/`RENAME_RETRY_MS`: a concurrent
|
|
169
|
+
// process (the server's own `mutatePool`) can briefly hold the target open
|
|
170
|
+
// (Windows EPERM) during exactly the race window documented in the README's
|
|
171
|
+
// "TUI pool writes" limitation. The server retries that race 5x; this synchronous
|
|
172
|
+
// counterpart (everything here is `*Sync`) now does the same via `Bun.sleepSync`
|
|
173
|
+
// instead of silently dropping the user's Rename/Delete click on the first transient failure.
|
|
174
|
+
const RENAME_RETRIES = 5
|
|
175
|
+
const RENAME_RETRY_MS = 20
|
|
176
|
+
|
|
177
|
+
/** Read-modify-write the pool file atomically (temp + rename — never a direct overwrite). */
|
|
178
|
+
export function mutatePoolFile(
|
|
179
|
+
fn: (pool: PoolShape) => void,
|
|
180
|
+
path: string = POOL_FILE,
|
|
181
|
+
ops: FsOps = realFsOps,
|
|
182
|
+
): void {
|
|
183
|
+
let tmp: string | undefined
|
|
184
|
+
try {
|
|
185
|
+
// Same guard as readPool: `fn` must never run on `null` / a non-object
|
|
186
|
+
// (JSON.parse('null') succeeds), and healing here also repairs the file.
|
|
187
|
+
const pool = toPoolShape(JSON.parse(ops.readFileSync(path, 'utf8')))
|
|
188
|
+
fn(pool)
|
|
189
|
+
const payload = JSON.stringify(pool, null, 2)
|
|
190
|
+
tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
191
|
+
ops.writeFileSync(tmp, payload, { mode: 0o600 })
|
|
192
|
+
let lastError: unknown
|
|
193
|
+
let renamed = false
|
|
194
|
+
for (let attempt = 0; attempt < RENAME_RETRIES; attempt++) {
|
|
195
|
+
try {
|
|
196
|
+
ops.renameSync(tmp, path)
|
|
197
|
+
renamed = true
|
|
198
|
+
break
|
|
199
|
+
} catch (error) {
|
|
200
|
+
lastError = error
|
|
201
|
+
// Separate ATTEMPTS only; after the final failure there is nothing
|
|
202
|
+
// left to wait for.
|
|
203
|
+
if (attempt < RENAME_RETRIES - 1) Bun.sleepSync(RENAME_RETRY_MS)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!renamed) throw lastError
|
|
207
|
+
tmp = undefined
|
|
208
|
+
} catch {
|
|
209
|
+
// Atomic temp+rename failed (e.g. the server briefly holds the file on
|
|
210
|
+
// Windows). NEVER fall back to a direct overwrite — that would shred a
|
|
211
|
+
// concurrent server write of usage/cooldown/session/tokenGen state
|
|
212
|
+
// (see src/pool/store.ts writeJsonAtomic, which was deliberately changed
|
|
213
|
+
// away from that fallback in iteration #0046). Mirror its cleanup so a
|
|
214
|
+
// sustained failure doesn't leave a stray .tmp behind; the user can retry.
|
|
215
|
+
if (tmp) {
|
|
216
|
+
try {
|
|
217
|
+
ops.unlinkSync(tmp)
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore — best-effort cleanup */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function renameInPool(
|
|
226
|
+
id: string,
|
|
227
|
+
label: string,
|
|
228
|
+
path: string = POOL_FILE,
|
|
229
|
+
): void {
|
|
230
|
+
mutatePoolFile((pool) => {
|
|
231
|
+
const acct = (pool.accounts ?? []).find((a) => a.id === id)
|
|
232
|
+
if (acct) acct.label = label
|
|
233
|
+
}, path)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Remove an account from the pool and drop any in-use / session references to it. */
|
|
237
|
+
export function deleteFromPool(id: string, path: string = POOL_FILE): void {
|
|
238
|
+
mutatePoolFile((pool) => {
|
|
239
|
+
pool.accounts = (pool.accounts ?? []).filter((a) => a.id !== id)
|
|
240
|
+
const lastSelected = pool.lastSelected ?? {}
|
|
241
|
+
for (const key of Object.keys(lastSelected)) {
|
|
242
|
+
if (lastSelected[key] === id) delete lastSelected[key]
|
|
243
|
+
}
|
|
244
|
+
const sessions = pool.sessions ?? {}
|
|
245
|
+
for (const key of Object.keys(sessions)) {
|
|
246
|
+
if (sessions[key]?.accountId === id) delete sessions[key]
|
|
247
|
+
}
|
|
248
|
+
}, path)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Normalize a raw (loosely-typed) pool account into the strict shape the shared
|
|
253
|
+
* scorer reads. The pool file is user-editable and read RAW here (the server
|
|
254
|
+
* normalizes at its own read boundary — `normalizeWindow`/`normalizeAccounts`
|
|
255
|
+
* in src/pool/store.ts), so a hand-edited string `resetAt`/`cooldownUntil`
|
|
256
|
+
* would otherwise survive `?? 0` into the scorer and poison its numeric
|
|
257
|
+
* comparisons (`isWindowExpired`, `stateOf`'s `> now`) until the server heals
|
|
258
|
+
* the file — guard with Number.isFinite, not just nullish or typeof:
|
|
259
|
+
* `JSON.parse('1e999') === Infinity` and `typeof Infinity === 'number'`, so a
|
|
260
|
+
* hand-edited `1e999` cooldownUntil would render "(cooldown)" forever and a
|
|
261
|
+
* `1e999` resetAt would render the literal "Infinityd" every poll.
|
|
262
|
+
*/
|
|
263
|
+
export function isFiniteNumber(v: unknown): v is number {
|
|
264
|
+
return Number.isFinite(v)
|
|
265
|
+
}
|
|
266
|
+
export function toScoreWindow(
|
|
267
|
+
w: UsageWindow | null | undefined,
|
|
268
|
+
): ScoreWindow | null {
|
|
269
|
+
return w && isFiniteNumber(w.utilization)
|
|
270
|
+
? {
|
|
271
|
+
utilization: w.utilization,
|
|
272
|
+
resetAt: isFiniteNumber(w.resetAt) && w.resetAt >= 0 ? w.resetAt : 0,
|
|
273
|
+
}
|
|
274
|
+
: null
|
|
275
|
+
}
|
|
276
|
+
export function toScore(a: PoolAccount): ScoreAccount {
|
|
277
|
+
return {
|
|
278
|
+
usage: {
|
|
279
|
+
hourly: toScoreWindow(a.usage?.hourly),
|
|
280
|
+
weekly: toScoreWindow(a.usage?.weekly),
|
|
281
|
+
},
|
|
282
|
+
cooldownUntil: isFiniteNumber(a.cooldownUntil) ? a.cooldownUntil : 0,
|
|
283
|
+
disabledReason: a.disabledReason ?? null,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Byte-deterministic ASCII string comparator (never `localeCompare` — keeps
|
|
289
|
+
* tier/provider ordering byte-identical regardless of host locale). Ships its
|
|
290
|
+
* own copy independent of `src/status.ts`'s identical helper — this file
|
|
291
|
+
* already documents that the TUI runtime cannot import `src/`.
|
|
292
|
+
*/
|
|
293
|
+
export function compareAscii(a: string, b: string): number {
|
|
294
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function pct(u: number | null | undefined): string {
|
|
298
|
+
return typeof u === 'number' ? `${Math.round(u * 100)}%` : '-'
|
|
299
|
+
}
|
|
300
|
+
export function until(resetAt: number | undefined, now: number): string {
|
|
301
|
+
// Finiteness guard, not just truthiness/typeof: this reads the RAW
|
|
302
|
+
// pool-file value (see the callers' "deliberately stays on the RAW
|
|
303
|
+
// resetAt" note), so a hand-edited string (`"2026-01-01"`) would make
|
|
304
|
+
// `resetAt - now` NaN and render the literal "NaNd" — and a `1e999`
|
|
305
|
+
// (`Infinity`, which typeof calls a number) the literal "Infinityd" —
|
|
306
|
+
// every poll until the server heals the file.
|
|
307
|
+
if (!isFiniteNumber(resetAt) || resetAt <= now) return '-'
|
|
308
|
+
// Mirror the server's `relTime` (src/status.ts) semantics: floor minutes at 1
|
|
309
|
+
// (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
|
|
312
|
+
// 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 —
|
|
315
|
+
// not byte-identical output.
|
|
316
|
+
const mins = Math.max(1, Math.round((resetAt - now) / 60_000))
|
|
317
|
+
if (mins < 60) return `${mins}m`
|
|
318
|
+
const hrs = Math.floor(mins / 60)
|
|
319
|
+
if (hrs < 24) return `${hrs}h`
|
|
320
|
+
return `${Math.floor(hrs / 24)}d`
|
|
321
|
+
}
|
|
322
|
+
/** A window's utilization for the bar: "-" when absent, "0%" once it has reset (stale value dropped). */
|
|
323
|
+
export function winPct(w: UsageWindow | null | undefined, now: number): string {
|
|
324
|
+
return pct(displayUtil(toScoreWindow(w), now))
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The account's active model-tier cooldowns as display pairs, read from the
|
|
328
|
+
* RAW pool file: entries must be finiteness-guarded like every other raw read
|
|
329
|
+
* here (a hand-edited `1e999` → Infinity would render "opus" forever), and
|
|
330
|
+
* the LEGACY `opusCooldownUntil` field is folded in (max-merged) until the
|
|
331
|
+
* server migrates the file. Sorted by tier name (raw `< / >`, no locale) so
|
|
332
|
+
* the bar can never disagree with the CLI/tool dashboard's ordering.
|
|
333
|
+
*/
|
|
334
|
+
export function tierResets(a: PoolAccount, now: number): [string, number][] {
|
|
335
|
+
const raw = a.modelCooldownsUntil
|
|
336
|
+
const merged: Record<string, number> = {}
|
|
337
|
+
if (isPlainRecordValue(raw)) {
|
|
338
|
+
for (const [tier, resetAt] of Object.entries(
|
|
339
|
+
raw as Record<string, unknown>,
|
|
340
|
+
)) {
|
|
341
|
+
if (isFiniteNumber(resetAt) && resetAt > now) merged[tier] = resetAt
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (isFiniteNumber(a.opusCooldownUntil) && a.opusCooldownUntil > now) {
|
|
345
|
+
merged.opus = Math.max(merged.opus ?? 0, a.opusCooldownUntil)
|
|
346
|
+
}
|
|
347
|
+
return Object.entries(merged).sort(([x], [y]) => compareAscii(x, y))
|
|
348
|
+
}
|
|
349
|
+
export function stateOf(
|
|
350
|
+
sa: ScoreAccount,
|
|
351
|
+
tiers: [string, number][],
|
|
352
|
+
now: number,
|
|
353
|
+
): string {
|
|
354
|
+
if (sa.disabledReason) return 're-login'
|
|
355
|
+
if (sa.cooldownUntil > now) return `cooldown ${until(sa.cooldownUntil, now)}`
|
|
356
|
+
if (isExhausted(sa, cfg, now)) return 'full'
|
|
357
|
+
// A model-tier limit keeps the account usable (other models + downgrade),
|
|
358
|
+
// so it annotates rather than sidelines — mirrors src/status.ts.
|
|
359
|
+
if (tiers.length > 0)
|
|
360
|
+
return tiers.map(([tier, at]) => `${tier} ${until(at, now)}`).join(' · ')
|
|
361
|
+
return ''
|
|
362
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode TUI plugin ENTRY for the load balancer's persistent bottom status bar.
|
|
3
|
+
*
|
|
4
|
+
* Why this is a `.ts` entry that lazily imports a `.tsx` view:
|
|
5
|
+
* - opencode discovers file plugins with the glob `{plugin,plugins}/*.{ts,js}`
|
|
6
|
+
* (packages/opencode/src/config/plugin.ts) — `.tsx` is NOT scanned, so a
|
|
7
|
+
* hand-authored `.tsx` is silently skipped and never loads.
|
|
8
|
+
* - opencode's TUI runtime registers the @opentui/solid JSX transform whose Bun
|
|
9
|
+
* onLoad filter is `/\.(js|ts)x$/` — i.e. it only transforms `.tsx`/`.jsx`.
|
|
10
|
+
* A `.ts` file therefore cannot itself contain JSX, but it CAN import a `.tsx`
|
|
11
|
+
* module, which the runtime transform compiles correctly.
|
|
12
|
+
* - The `.tsx` is imported LAZILY inside `tui()` (which only ever runs in the TUI
|
|
13
|
+
* runtime where the transform is registered), so the server plugin worker never
|
|
14
|
+
* evaluates the SolidJS/@opentui module graph.
|
|
15
|
+
*
|
|
16
|
+
* Install: copy all THREE files (this entry, `auth-load-balancer-tui.view.tsx`,
|
|
17
|
+
* and `auth-load-balancer-scoring.ts`) into a directory the server does NOT
|
|
18
|
+
* scan — anything other than `plugin/`/`plugins/`, e.g.
|
|
19
|
+
* `~/.config/opencode/tui-plugins/` — then register THIS entry in
|
|
20
|
+
* `~/.config/opencode/tui.json`'s `plugin` array (`file:///…/auth-load-balancer-tui.ts`)
|
|
21
|
+
* and restart. Do NOT drop these files in a `plugins/` dir: the server globs
|
|
22
|
+
* `{plugin,plugins}/*.{ts,js}` and would load the entry as a server plugin,
|
|
23
|
+
* reject it (`must default export … server()`), and log an error on every
|
|
24
|
+
* launch — see the README's "Why this shape (it matters)" section.
|
|
25
|
+
*/
|
|
26
|
+
import type { TuiPlugin, TuiPluginModule } from '@opencode-ai/plugin/tui'
|
|
27
|
+
|
|
28
|
+
const tui: TuiPlugin = async (api) => {
|
|
29
|
+
// Lazy so the server worker (no Solid transform) never imports the .tsx graph.
|
|
30
|
+
const { registerSlots } = await import('./auth-load-balancer-tui.view.tsx')
|
|
31
|
+
registerSlots(api)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
35
|
+
id: 'auth-load-balancer-tui',
|
|
36
|
+
tui,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default plugin
|