@thelioo/opencode-balancer 0.1.8 → 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.
Files changed (176) hide show
  1. package/INSTALL.txt +53 -25
  2. package/README.md +95 -51
  3. package/dist/core/accounts.ts +404 -0
  4. package/dist/core/database.ts +67 -0
  5. package/dist/core/events.ts +75 -0
  6. package/dist/core/native-auth-suppression.ts +36 -0
  7. package/dist/core/native-connect.ts +31 -0
  8. package/dist/core/path.ts +34 -0
  9. package/dist/core/pending.ts +351 -0
  10. package/dist/core/priority.ts +193 -0
  11. package/dist/core/schema.ts +439 -0
  12. package/dist/core/time.ts +3 -0
  13. package/dist/core/types.ts +72 -0
  14. package/dist/core/usage/index.ts +23 -0
  15. package/dist/core/usage/providers/copilot.ts +243 -0
  16. package/dist/core/usage/providers/openai.ts +179 -0
  17. package/dist/core/usage/redact.ts +80 -0
  18. package/dist/core/usage/store.ts +66 -0
  19. package/dist/core/usage/types.ts +24 -0
  20. package/dist/index.js +173 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/server/auth-watcher.ts +318 -0
  23. package/dist/server/commands.ts +58 -0
  24. package/dist/server/fetch-patch.ts +162 -0
  25. package/dist/server/index.ts +134 -0
  26. package/dist/server/native.ts +49 -0
  27. package/dist/server/request-balancer.ts +67 -0
  28. package/dist/tui/actions.ts +176 -112
  29. package/dist/tui/balancer-bar-sync.ts +55 -45
  30. package/dist/tui/components/alias-dialog.tsx +71 -56
  31. package/dist/tui/components/dashboard.tsx +530 -358
  32. package/dist/tui/components/priority-screen.tsx +389 -267
  33. package/dist/tui/components/provider-model-dialog.tsx +71 -64
  34. package/dist/tui/components/rename-dialog.tsx +35 -28
  35. package/dist/tui/components/sidebar.tsx +103 -79
  36. package/dist/tui/components/status-indicator.tsx +78 -59
  37. package/dist/tui/components/usage-bar.tsx +18 -7
  38. package/dist/tui/components/usage-display.tsx +32 -16
  39. package/dist/tui/connect.ts +104 -73
  40. package/dist/tui/dashboard-keys.ts +53 -41
  41. package/dist/tui/native-model-apply.ts +45 -36
  42. package/dist/tui/priority-keys.ts +44 -36
  43. package/dist/tui/provider-models.ts +32 -25
  44. package/dist/tui/responsive.ts +10 -7
  45. package/dist/tui/selected-account-bar-sync.ts +23 -23
  46. package/dist/tui/selection-colors.ts +38 -30
  47. package/dist/tui/state.ts +61 -44
  48. package/dist/tui/status-format.ts +24 -20
  49. package/dist/tui/tui.js +165 -153
  50. package/dist/tui/tui.js.map +1 -1
  51. package/dist/tui/tui.tsx +194 -144
  52. package/dist/tui/usage-auto-refresh.ts +52 -45
  53. package/dist/tui/usage-format.ts +9 -9
  54. package/package.json +61 -52
  55. package/dist/core/accounts.d.ts +0 -14
  56. package/dist/core/accounts.js +0 -260
  57. package/dist/core/accounts.js.map +0 -1
  58. package/dist/core/database.d.ts +0 -4
  59. package/dist/core/database.js +0 -69
  60. package/dist/core/database.js.map +0 -1
  61. package/dist/core/events.d.ts +0 -18
  62. package/dist/core/events.js +0 -39
  63. package/dist/core/events.js.map +0 -1
  64. package/dist/core/native-auth-suppression.d.ts +0 -3
  65. package/dist/core/native-auth-suppression.js +0 -19
  66. package/dist/core/native-auth-suppression.js.map +0 -1
  67. package/dist/core/native-connect.d.ts +0 -4
  68. package/dist/core/native-connect.js +0 -19
  69. package/dist/core/native-connect.js.map +0 -1
  70. package/dist/core/path.d.ts +0 -4
  71. package/dist/core/path.js +0 -26
  72. package/dist/core/path.js.map +0 -1
  73. package/dist/core/pending.d.ts +0 -9
  74. package/dist/core/pending.js +0 -237
  75. package/dist/core/pending.js.map +0 -1
  76. package/dist/core/priority.d.ts +0 -20
  77. package/dist/core/priority.js +0 -120
  78. package/dist/core/priority.js.map +0 -1
  79. package/dist/core/schema.d.ts +0 -2
  80. package/dist/core/schema.js +0 -265
  81. package/dist/core/schema.js.map +0 -1
  82. package/dist/core/time.d.ts +0 -1
  83. package/dist/core/time.js +0 -4
  84. package/dist/core/time.js.map +0 -1
  85. package/dist/core/types.d.ts +0 -59
  86. package/dist/core/types.js +0 -2
  87. package/dist/core/types.js.map +0 -1
  88. package/dist/core/usage/index.d.ts +0 -4
  89. package/dist/core/usage/index.js +0 -16
  90. package/dist/core/usage/index.js.map +0 -1
  91. package/dist/core/usage/providers/copilot.d.ts +0 -2
  92. package/dist/core/usage/providers/copilot.js +0 -169
  93. package/dist/core/usage/providers/copilot.js.map +0 -1
  94. package/dist/core/usage/providers/openai.d.ts +0 -2
  95. package/dist/core/usage/providers/openai.js +0 -133
  96. package/dist/core/usage/providers/openai.js.map +0 -1
  97. package/dist/core/usage/redact.d.ts +0 -3
  98. package/dist/core/usage/redact.js +0 -67
  99. package/dist/core/usage/redact.js.map +0 -1
  100. package/dist/core/usage/store.d.ts +0 -4
  101. package/dist/core/usage/store.js +0 -31
  102. package/dist/core/usage/store.js.map +0 -1
  103. package/dist/core/usage/types.d.ts +0 -21
  104. package/dist/core/usage/types.js +0 -2
  105. package/dist/core/usage/types.js.map +0 -1
  106. package/dist/index.d.ts +0 -5
  107. package/dist/server/auth-watcher.d.ts +0 -32
  108. package/dist/server/auth-watcher.js +0 -227
  109. package/dist/server/auth-watcher.js.map +0 -1
  110. package/dist/server/commands.d.ts +0 -2
  111. package/dist/server/commands.js +0 -46
  112. package/dist/server/commands.js.map +0 -1
  113. package/dist/server/fetch-patch.d.ts +0 -3
  114. package/dist/server/fetch-patch.js +0 -118
  115. package/dist/server/fetch-patch.js.map +0 -1
  116. package/dist/server/index.d.ts +0 -8
  117. package/dist/server/index.js +0 -94
  118. package/dist/server/index.js.map +0 -1
  119. package/dist/server/native.d.ts +0 -6
  120. package/dist/server/native.js +0 -35
  121. package/dist/server/native.js.map +0 -1
  122. package/dist/server/request-balancer.d.ts +0 -16
  123. package/dist/server/request-balancer.js +0 -43
  124. package/dist/server/request-balancer.js.map +0 -1
  125. package/dist/tui/actions.d.ts +0 -41
  126. package/dist/tui/actions.js +0 -92
  127. package/dist/tui/actions.js.map +0 -1
  128. package/dist/tui/balancer-bar-sync.d.ts +0 -19
  129. package/dist/tui/balancer-bar-sync.js +0 -45
  130. package/dist/tui/balancer-bar-sync.js.map +0 -1
  131. package/dist/tui/components/alias-dialog.d.ts +0 -4
  132. package/dist/tui/components/dashboard.d.ts +0 -12
  133. package/dist/tui/components/priority-screen.d.ts +0 -9
  134. package/dist/tui/components/provider-model-dialog.d.ts +0 -14
  135. package/dist/tui/components/rename-dialog.d.ts +0 -4
  136. package/dist/tui/components/sidebar.d.ts +0 -10
  137. package/dist/tui/components/status-indicator.d.ts +0 -9
  138. package/dist/tui/components/usage-bar.d.ts +0 -8
  139. package/dist/tui/components/usage-display.d.ts +0 -10
  140. package/dist/tui/connect.d.ts +0 -30
  141. package/dist/tui/connect.js +0 -75
  142. package/dist/tui/connect.js.map +0 -1
  143. package/dist/tui/dashboard-keys.d.ts +0 -45
  144. package/dist/tui/dashboard-keys.js +0 -44
  145. package/dist/tui/dashboard-keys.js.map +0 -1
  146. package/dist/tui/native-model-apply.d.ts +0 -21
  147. package/dist/tui/native-model-apply.js +0 -53
  148. package/dist/tui/native-model-apply.js.map +0 -1
  149. package/dist/tui/priority-keys.d.ts +0 -40
  150. package/dist/tui/priority-keys.js +0 -38
  151. package/dist/tui/priority-keys.js.map +0 -1
  152. package/dist/tui/provider-models.d.ts +0 -19
  153. package/dist/tui/provider-models.js +0 -17
  154. package/dist/tui/provider-models.js.map +0 -1
  155. package/dist/tui/responsive.d.ts +0 -9
  156. package/dist/tui/responsive.js +0 -13
  157. package/dist/tui/responsive.js.map +0 -1
  158. package/dist/tui/selected-account-bar-sync.d.ts +0 -10
  159. package/dist/tui/selected-account-bar-sync.js +0 -26
  160. package/dist/tui/selected-account-bar-sync.js.map +0 -1
  161. package/dist/tui/selection-colors.d.ts +0 -10
  162. package/dist/tui/selection-colors.js +0 -38
  163. package/dist/tui/selection-colors.js.map +0 -1
  164. package/dist/tui/state.d.ts +0 -14
  165. package/dist/tui/state.js +0 -46
  166. package/dist/tui/state.js.map +0 -1
  167. package/dist/tui/status-format.d.ts +0 -15
  168. package/dist/tui/status-format.js +0 -17
  169. package/dist/tui/status-format.js.map +0 -1
  170. package/dist/tui/tui.d.ts +0 -7
  171. package/dist/tui/usage-auto-refresh.d.ts +0 -16
  172. package/dist/tui/usage-auto-refresh.js +0 -46
  173. package/dist/tui/usage-auto-refresh.js.map +0 -1
  174. package/dist/tui/usage-format.d.ts +0 -2
  175. package/dist/tui/usage-format.js +0 -17
  176. package/dist/tui/usage-format.js.map +0 -1
@@ -0,0 +1,351 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { saveAccount } from "./accounts";
4
+ import { now } from "./time";
5
+ import type { AuthInfo, PendingConnection } from "./types";
6
+
7
+ type PendingConnectionRow = {
8
+ id: string;
9
+ provider_id: string;
10
+ auth_json: string;
11
+ auth_type: string;
12
+ source: string;
13
+ captured_at: number;
14
+ prompt_status: string;
15
+ };
16
+
17
+ type PendingIdentity = {
18
+ providerID: string;
19
+ auth: AuthInfo;
20
+ source: PendingConnection["source"];
21
+ };
22
+
23
+ const pendingSources = ["auth-file", "http", "oauth-callback"] as const;
24
+ const promptStatuses = ["new", "prompted", "dismissed"] as const;
25
+ const authTypes = ["api", "oauth", "wellknown"] as const;
26
+
27
+ function isAuthType(value: string): value is AuthInfo["type"] {
28
+ return authTypes.includes(value as AuthInfo["type"]);
29
+ }
30
+
31
+ function parsePendingAuth(row: PendingConnectionRow): AuthInfo {
32
+ let auth: unknown;
33
+ try {
34
+ auth = JSON.parse(row.auth_json);
35
+ } catch {
36
+ throw new Error(
37
+ `Invalid pending connection row ${row.id}: auth_json is invalid`,
38
+ );
39
+ }
40
+
41
+ if (
42
+ !auth ||
43
+ typeof auth !== "object" ||
44
+ !("type" in auth) ||
45
+ typeof auth.type !== "string"
46
+ ) {
47
+ throw new Error(
48
+ `Invalid pending connection row ${row.id}: auth_json type is invalid`,
49
+ );
50
+ }
51
+ if (!isAuthType(auth.type)) {
52
+ throw new Error(
53
+ `Invalid pending connection row ${row.id}: auth_json type ${auth.type} is invalid`,
54
+ );
55
+ }
56
+ return auth as AuthInfo;
57
+ }
58
+
59
+ function validatePendingAuthType(row: PendingConnectionRow): AuthInfo["type"] {
60
+ if (!isAuthType(row.auth_type)) {
61
+ throw new Error(
62
+ `Invalid pending connection row ${row.id}: auth_type ${row.auth_type} is invalid`,
63
+ );
64
+ }
65
+ return row.auth_type;
66
+ }
67
+
68
+ function validatePendingSource(
69
+ row: PendingConnectionRow,
70
+ ): PendingConnection["source"] {
71
+ if (!pendingSources.includes(row.source as PendingConnection["source"])) {
72
+ throw new Error(
73
+ `Invalid pending connection row ${row.id}: source ${row.source} is invalid`,
74
+ );
75
+ }
76
+ return row.source as PendingConnection["source"];
77
+ }
78
+
79
+ function validatePromptStatus(
80
+ row: PendingConnectionRow,
81
+ ): PendingConnection["promptStatus"] {
82
+ if (
83
+ !promptStatuses.includes(
84
+ row.prompt_status as PendingConnection["promptStatus"],
85
+ )
86
+ ) {
87
+ throw new Error(
88
+ `Invalid pending connection row ${row.id}: prompt_status ${row.prompt_status} is invalid`,
89
+ );
90
+ }
91
+ return row.prompt_status as PendingConnection["promptStatus"];
92
+ }
93
+
94
+ function pendingConnectionFromRow(
95
+ row: PendingConnectionRow,
96
+ ): PendingConnection {
97
+ const auth = parsePendingAuth(row);
98
+ const authType = validatePendingAuthType(row);
99
+ if (auth.type !== authType) {
100
+ throw new Error(
101
+ `Invalid pending connection row ${row.id}: auth_type ${authType} does not match auth_json type ${auth.type}`,
102
+ );
103
+ }
104
+
105
+ return {
106
+ auth,
107
+ authType,
108
+ capturedAt: row.captured_at,
109
+ id: row.id,
110
+ promptStatus: validatePromptStatus(row),
111
+ providerID: row.provider_id,
112
+ source: validatePendingSource(row),
113
+ };
114
+ }
115
+
116
+ function authIdentityKey(auth: AuthInfo) {
117
+ if (auth.type === "api") return `api\0${auth.key}`;
118
+ if (auth.type === "wellknown") return `wellknown\0${auth.key}\0${auth.token}`;
119
+ const accountID = oauthAccountID(auth);
120
+ return accountID
121
+ ? `oauth-account\0${accountID}`
122
+ : `oauth-refresh\0${auth.refresh}`;
123
+ }
124
+
125
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
126
+ const payload = token.split(".")[1];
127
+ if (!payload) return undefined;
128
+ try {
129
+ const decoded = JSON.parse(
130
+ Buffer.from(payload, "base64url").toString("utf8"),
131
+ ) as unknown;
132
+ return decoded && typeof decoded === "object" && !Array.isArray(decoded)
133
+ ? (decoded as Record<string, unknown>)
134
+ : undefined;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ function oauthAccountID(auth: Extract<AuthInfo, { type: "oauth" }>) {
141
+ if (auth.accountId) return auth.accountId;
142
+ const claim = decodeJwtPayload(auth.access)?.["https://api.openai.com/auth"];
143
+ if (!claim || typeof claim !== "object" || Array.isArray(claim))
144
+ return undefined;
145
+ const accountID = (claim as Record<string, unknown>).chatgpt_account_id;
146
+ return typeof accountID === "string" && accountID.length > 0
147
+ ? accountID
148
+ : undefined;
149
+ }
150
+
151
+ function samePendingIdentity(auth: AuthInfo, other: AuthInfo) {
152
+ return authIdentityKey(auth) === authIdentityKey(other);
153
+ }
154
+
155
+ function pendingID(
156
+ providerID: string,
157
+ auth: AuthInfo,
158
+ source: PendingConnection["source"],
159
+ ) {
160
+ const hash = createHash("sha256")
161
+ .update(`${providerID}\0${source}\0${authIdentityKey(auth)}`)
162
+ .digest("hex")
163
+ .slice(0, 32);
164
+ return `pending-${hash}`;
165
+ }
166
+
167
+ export function pendingPromptGroupID(db: Database, id: string) {
168
+ const row = selectPendingRow(db, id);
169
+ if (!row) return undefined;
170
+ const identity = pendingIdentity(row);
171
+ return `${identity.providerID}\0${identity.source}\0${authIdentityKey(identity.auth)}`;
172
+ }
173
+
174
+ function selectPendingRow(db: Database, id: string) {
175
+ return db
176
+ .query<PendingConnectionRow, [string]>(
177
+ `SELECT id, provider_id, auth_json, auth_type, source, captured_at, prompt_status
178
+ FROM pending_connections
179
+ WHERE id = ?`,
180
+ )
181
+ .get(id);
182
+ }
183
+
184
+ function pendingIdentity(row: PendingConnectionRow): PendingIdentity {
185
+ return {
186
+ auth: parsePendingAuth(row),
187
+ providerID: row.provider_id,
188
+ source: validatePendingSource(row),
189
+ };
190
+ }
191
+
192
+ function equivalentPendingRows(db: Database, identity: PendingIdentity) {
193
+ return db
194
+ .query<PendingConnectionRow, [string, string, string]>(
195
+ `SELECT id, provider_id, auth_json, auth_type, source, captured_at, prompt_status
196
+ FROM pending_connections
197
+ WHERE provider_id = ? AND source = ? AND auth_type = ?`,
198
+ )
199
+ .all(identity.providerID, identity.source, identity.auth.type)
200
+ .filter((row) => samePendingIdentity(parsePendingAuth(row), identity.auth));
201
+ }
202
+
203
+ export function createPendingConnection(
204
+ db: Database,
205
+ providerID: string,
206
+ auth: AuthInfo,
207
+ source: PendingConnection["source"],
208
+ ): PendingConnection {
209
+ const authJSON = JSON.stringify(auth);
210
+ const identity = { auth, providerID, source } satisfies PendingIdentity;
211
+ const existing = equivalentPendingRows(db, identity)[0];
212
+ if (existing) return pendingConnectionFromRow(existing);
213
+
214
+ const pending: PendingConnection = {
215
+ auth,
216
+ authType: auth.type,
217
+ capturedAt: now(),
218
+ id: pendingID(providerID, auth, source),
219
+ promptStatus: "new",
220
+ providerID,
221
+ source,
222
+ };
223
+
224
+ db.query<unknown, [string, string, string, string, string, number, string]>(
225
+ `INSERT INTO pending_connections (
226
+ id,
227
+ provider_id,
228
+ auth_json,
229
+ auth_type,
230
+ source,
231
+ captured_at,
232
+ prompt_status
233
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
234
+ ON CONFLICT(id) DO NOTHING`,
235
+ ).run(
236
+ pending.id,
237
+ pending.providerID,
238
+ authJSON,
239
+ pending.authType,
240
+ pending.source,
241
+ pending.capturedAt,
242
+ pending.promptStatus,
243
+ );
244
+
245
+ const row = selectPendingRow(db, pending.id);
246
+ return row ? pendingConnectionFromRow(row) : pending;
247
+ }
248
+
249
+ export function claimPendingPrompt(db: Database, id: string) {
250
+ const claim = db.transaction(() => {
251
+ const row = selectPendingRow(db, id);
252
+ if (!row) return undefined;
253
+ const identity = pendingIdentity(row);
254
+ const equivalent = equivalentPendingRows(db, identity);
255
+ const ids = equivalent.map((pending) => pending.id);
256
+ const result = db
257
+ .query<unknown, string[]>(
258
+ `UPDATE pending_connections
259
+ SET prompt_status = 'prompted'
260
+ WHERE id IN (${ids.map(() => "?").join(", ")})
261
+ AND prompt_status = 'new'`,
262
+ )
263
+ .run(...ids);
264
+ if (result.changes === 0) return undefined;
265
+ return pendingConnectionFromRow({ ...row, prompt_status: "prompted" });
266
+ });
267
+
268
+ return claim();
269
+ }
270
+
271
+ export function releasePendingPrompt(db: Database, id: string) {
272
+ const release = db.transaction(() => {
273
+ const row = selectPendingRow(db, id);
274
+ if (!row) return false;
275
+ const identity = pendingIdentity(row);
276
+ const ids = equivalentPendingRows(db, identity).map(
277
+ (pending) => pending.id,
278
+ );
279
+ if (ids.length === 0) return false;
280
+ const result = db
281
+ .query<unknown, string[]>(
282
+ `UPDATE pending_connections
283
+ SET prompt_status = 'new'
284
+ WHERE id IN (${ids.map(() => "?").join(", ")})
285
+ AND prompt_status = 'prompted'`,
286
+ )
287
+ .run(...ids);
288
+ return result.changes > 0;
289
+ });
290
+
291
+ return release();
292
+ }
293
+
294
+ export function listPendingConnections(db: Database): PendingConnection[] {
295
+ const rows = db
296
+ .query<PendingConnectionRow, []>(
297
+ `SELECT id, provider_id, auth_json, auth_type, source, captured_at, prompt_status
298
+ FROM pending_connections
299
+ ORDER BY captured_at DESC, id DESC`,
300
+ )
301
+ .all();
302
+ return rows.map(pendingConnectionFromRow);
303
+ }
304
+
305
+ export function completePendingConnection(
306
+ db: Database,
307
+ id: string,
308
+ alias: string,
309
+ ) {
310
+ const complete = db.transaction(() => {
311
+ const row = selectPendingRow(db, id);
312
+ if (!row) throw new Error(`Pending connection not found: ${id}`);
313
+
314
+ const pending = pendingConnectionFromRow(row);
315
+ const account = saveAccount(db, pending.providerID, alias, pending.auth);
316
+ const identity = pendingIdentity(row);
317
+ const ids = equivalentPendingRows(db, identity).map(
318
+ (pending) => pending.id,
319
+ );
320
+ db.query<unknown, string[]>(
321
+ `DELETE FROM pending_connections
322
+ WHERE id IN (${ids.map(() => "?").join(", ")})`,
323
+ ).run(...ids);
324
+ return account;
325
+ });
326
+
327
+ return complete();
328
+ }
329
+
330
+ export function removePendingConnection(db: Database, id: string) {
331
+ const remove = db.transaction(() => {
332
+ const row = selectPendingRow(db, id);
333
+ if (!row) return false;
334
+ const identity = pendingIdentity(row);
335
+ const ids = equivalentPendingRows(db, identity).map(
336
+ (pending) => pending.id,
337
+ );
338
+ if (ids.length === 0) return false;
339
+ const result = db
340
+ .query<unknown, string[]>(
341
+ `UPDATE pending_connections
342
+ SET prompt_status = 'dismissed'
343
+ WHERE id IN (${ids.map(() => "?").join(", ")})
344
+ AND prompt_status != 'dismissed'`,
345
+ )
346
+ .run(...ids);
347
+ return result.changes > 0;
348
+ });
349
+
350
+ return remove();
351
+ }
@@ -0,0 +1,193 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { getActiveAccount, listAccounts } from "./accounts";
3
+ import { now } from "./time";
4
+ import type { Account } from "./types";
5
+
6
+ export type PriorityEntry = {
7
+ providerID: string;
8
+ position: number;
9
+ modelID?: string;
10
+ enabled: boolean;
11
+ };
12
+
13
+ export type ActiveSelection = {
14
+ providerID: string;
15
+ modelID: string;
16
+ account: Account;
17
+ };
18
+
19
+ type PriorityRow = {
20
+ provider_id: string;
21
+ position: number;
22
+ model_id: string | null;
23
+ enabled: number;
24
+ };
25
+
26
+ function storedRows(db: Database): Map<string, PriorityRow> {
27
+ const rows = db
28
+ .query<PriorityRow, []>(
29
+ "SELECT provider_id, position, model_id, enabled FROM provider_priority",
30
+ )
31
+ .all();
32
+ return new Map(rows.map((row) => [row.provider_id, row]));
33
+ }
34
+
35
+ function providersWithAccounts(db: Database): string[] {
36
+ return [...new Set(listAccounts(db).map((account) => account.providerID))];
37
+ }
38
+
39
+ function nextPosition(stored: Map<string, PriorityRow>): number {
40
+ let max = -1;
41
+ for (const row of stored.values()) max = Math.max(max, row.position);
42
+ return max + 1;
43
+ }
44
+
45
+ function upsert(
46
+ db: Database,
47
+ providerID: string,
48
+ patch: { position?: number; modelID?: string | null; enabled?: boolean },
49
+ ) {
50
+ const stored = storedRows(db);
51
+ const existing = stored.get(providerID);
52
+ const position = patch.position ?? existing?.position ?? nextPosition(stored);
53
+ const modelID =
54
+ patch.modelID !== undefined ? patch.modelID : (existing?.model_id ?? null);
55
+ const enabled =
56
+ patch.enabled !== undefined
57
+ ? patch.enabled
58
+ ? 1
59
+ : 0
60
+ : (existing?.enabled ?? 1);
61
+
62
+ db.query<unknown, [string, number, string | null, number, number]>(
63
+ `INSERT INTO provider_priority (provider_id, position, model_id, enabled, updated_at)
64
+ VALUES (?, ?, ?, ?, ?)
65
+ ON CONFLICT(provider_id) DO UPDATE SET
66
+ position = excluded.position,
67
+ model_id = excluded.model_id,
68
+ enabled = excluded.enabled,
69
+ updated_at = excluded.updated_at`,
70
+ ).run(providerID, position, modelID, enabled, now());
71
+ }
72
+
73
+ export function listProviderPriority(db: Database): PriorityEntry[] {
74
+ const stored = storedRows(db);
75
+ const providers = providersWithAccounts(db);
76
+
77
+ const ordered = providers.slice().sort((a, b) => {
78
+ const ra = stored.get(a);
79
+ const rb = stored.get(b);
80
+ if (ra && rb) return ra.position - rb.position || a.localeCompare(b);
81
+ if (ra) return -1;
82
+ if (rb) return 1;
83
+ return a.localeCompare(b);
84
+ });
85
+
86
+ return ordered.map((providerID, index) => {
87
+ const row = stored.get(providerID);
88
+ return {
89
+ enabled: row ? row.enabled === 1 : true,
90
+ modelID: row?.model_id ?? undefined,
91
+ position: index,
92
+ providerID,
93
+ };
94
+ });
95
+ }
96
+
97
+ export function setProviderModel(
98
+ db: Database,
99
+ providerID: string,
100
+ modelID: string,
101
+ ) {
102
+ upsert(db, providerID, { modelID });
103
+ }
104
+
105
+ export function setProviderEnabled(
106
+ db: Database,
107
+ providerID: string,
108
+ enabled: boolean,
109
+ ) {
110
+ upsert(db, providerID, { enabled });
111
+ }
112
+
113
+ export function moveProvider(
114
+ db: Database,
115
+ providerID: string,
116
+ direction: -1 | 1,
117
+ ) {
118
+ const list = listProviderPriority(db);
119
+ const index = list.findIndex((entry) => entry.providerID === providerID);
120
+ if (index === -1) return;
121
+ const target = index + direction;
122
+ if (target < 0 || target >= list.length) return;
123
+
124
+ const reordered = list.slice();
125
+ const [moved] = reordered.splice(index, 1);
126
+ reordered.splice(target, 0, moved);
127
+
128
+ const persist = db.transaction(() => {
129
+ for (const [position, entry] of reordered.entries()) {
130
+ upsert(db, entry.providerID, { position });
131
+ }
132
+ });
133
+ persist();
134
+ }
135
+
136
+ export function getBalancingEnabled(db: Database): boolean {
137
+ const row = db
138
+ .query<{ value: string }, [string]>(
139
+ "SELECT value FROM settings WHERE key = ?",
140
+ )
141
+ .get("balancing_enabled");
142
+
143
+ return row?.value === "1";
144
+ }
145
+
146
+ export function setBalancingEnabled(db: Database, enabled: boolean) {
147
+ db.query<unknown, [string]>(
148
+ `INSERT INTO settings (key, value) VALUES ('balancing_enabled', ?)
149
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
150
+ ).run(enabled ? "1" : "0");
151
+ }
152
+
153
+ function chooseHealthyAccount(
154
+ db: Database,
155
+ providerID: string,
156
+ nowMs: number,
157
+ ): Account | undefined {
158
+ const healthy = listAccounts(db, providerID).filter(
159
+ (account) =>
160
+ !account.disabled &&
161
+ (!account.rateLimitedUntil || account.rateLimitedUntil <= nowMs),
162
+ );
163
+ if (healthy.length === 0) return undefined;
164
+
165
+ const active = getActiveAccount(db, providerID);
166
+ if (active && healthy.some((account) => account.alias === active.alias))
167
+ return active;
168
+ return healthy[0];
169
+ }
170
+
171
+ export function resolveActiveSelection(
172
+ db: Database,
173
+ nowMs: number = now(),
174
+ preferredProviderID?: string,
175
+ ): ActiveSelection | undefined {
176
+ const entries = listProviderPriority(db);
177
+ const ordered = preferredProviderID
178
+ ? entries.slice().sort((a, b) => {
179
+ if (a.providerID === preferredProviderID) return -1;
180
+ if (b.providerID === preferredProviderID) return 1;
181
+ return a.position - b.position;
182
+ })
183
+ : entries;
184
+
185
+ for (const entry of ordered) {
186
+ if (!entry.enabled) continue;
187
+ if (!entry.modelID) continue;
188
+ const account = chooseHealthyAccount(db, entry.providerID, nowMs);
189
+ if (!account) continue;
190
+ return { account, modelID: entry.modelID, providerID: entry.providerID };
191
+ }
192
+ return undefined;
193
+ }