dsh-multi-provider-usage 0.0.0-stage → 0.7.2

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.
@@ -0,0 +1,556 @@
1
+ // @ts-check
2
+ /**
3
+ * dsh-multi-provider-usage — OpenCode console balance helper.
4
+ *
5
+ * OpenCode GO quota and OpenCode Zen's prepaid balance are served by two
6
+ * different APIs:
7
+ *
8
+ * - `GET {gateway}/v1/usage` — GO plan windows (already used above);
9
+ * - `GET https://opencode.ai/console/api/billing/status` — the account's
10
+ * prepaid / top-up balance (`balanceMicroCents` / `availableMicroCents`).
11
+ *
12
+ * The console endpoint needs the OpenCode **console** OAuth token, not the GO
13
+ * API key. This module resolves that token, in priority order:
14
+ *
15
+ * 1. a DSH credential ref (`opencodeConsoleApiKeyEnv`);
16
+ * 2. OpenCode's local database (`opencode.db`, `credential` table), which is
17
+ * where current OpenCode releases keep the OAuth grant;
18
+ * 3. OpenCode's legacy `auth.json` (`~/.local/share/opencode/auth.json`, or
19
+ * `$XDG_DATA_HOME/opencode/auth.json`).
20
+ *
21
+ * Older releases wrote `auth.json`; current releases migrated the grant into
22
+ * the database but often leave the old file behind. The database is therefore
23
+ * preferred, and every usable candidate is tried in turn: a stale
24
+ * `auth.json` token must not shadow the live database grant. API-key logins
25
+ * (`oc_sk_…`, method "API key (service account)") are reported as "not a
26
+ * console session" instead of being sent to the billing endpoint, which only
27
+ * answers 403 for them.
28
+ *
29
+ * Reading the local OpenCode store is optional and can be disabled with
30
+ * `opencodeConsoleEnabled: false`. Values never leave the host process; the
31
+ * settings/detection payload reports only the source, org id and whether a
32
+ * credential was found. `node:sqlite` is used opportunistically (Node 22.5+)
33
+ * and a runtime without it simply falls through to the auth file.
34
+ */
35
+
36
+ import { readFileSync, readdirSync, statSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { dirname, isAbsolute, join } from "node:path";
39
+ import { isCredentialRefName, uniqueStrings } from "./util.js";
40
+
41
+ /** Default credential ref for a manually supplied console token. */
42
+ export const DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV = "OPENCODE_CONSOLE_TOKEN";
43
+ /** Default OpenCode console origin. */
44
+ export const DEFAULT_OPENCODE_CONSOLE_BASE_URL = "https://opencode.ai";
45
+
46
+ /**
47
+ * A balance read failure with a stable code (no token in the message).
48
+ * `status` carries the console HTTP status when there was one.
49
+ */
50
+ export class ConsoleError extends Error {
51
+ constructor(code, message, status = null) {
52
+ super(message);
53
+ this.name = "ConsoleError";
54
+ this.code = code;
55
+ this.status = status;
56
+ }
57
+ }
58
+
59
+ /** The OpenCode data directory, honouring XDG_DATA_HOME. */
60
+ export function opencodeDataDir(env = process.env) {
61
+ const xdg = typeof env.XDG_DATA_HOME === "string" ? env.XDG_DATA_HOME.trim() : "";
62
+ return xdg.length > 0 ? join(xdg, "opencode") : join(homedir(), ".local", "share", "opencode");
63
+ }
64
+
65
+ /** Default OpenCode `auth.json` location. */
66
+ export function defaultOpencodeAuthFile(env = process.env) {
67
+ return join(opencodeDataDir(env), "auth.json");
68
+ }
69
+
70
+ /** Default OpenCode `opencode.db` location. */
71
+ export function defaultOpencodeDbFile(env = process.env) {
72
+ return join(opencodeDataDir(env), "opencode.db");
73
+ }
74
+
75
+ /** Expand `~` in a user-supplied path. */
76
+ function expandHome(pathValue) {
77
+ const raw = String(pathValue ?? "").trim();
78
+ if (raw === "") return "";
79
+ if (raw === "~") return homedir();
80
+ if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
81
+ return raw;
82
+ }
83
+
84
+ /**
85
+ * Normalize an `expires` value (OpenCode stores epoch milliseconds) to ms.
86
+ * Values below 10^10 are treated as seconds, defensively.
87
+ */
88
+ function expiryMs(value) {
89
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
90
+ return value < 10_000_000_000 ? value * 1000 : value;
91
+ }
92
+
93
+ /**
94
+ * Classify a stored token. Console OAuth access tokens are `st_…`; OpenCode
95
+ * service-account keys (the GO API-key login) are `oc_sk_…`. An explicit
96
+ * `type` wins; unknown shapes are treated as OAuth so a manually pasted
97
+ * console token still works.
98
+ */
99
+ function tokenKind(token, type) {
100
+ const normalized = String(type ?? "").toLowerCase();
101
+ if (normalized === "key" || normalized === "api" || normalized === "api-key") return "api-key";
102
+ if (normalized === "oauth") return "oauth";
103
+ if (/^st_/.test(token)) return "oauth";
104
+ // Service-account keys (`oc_sk_…`) authenticate the API, not the console.
105
+ if (/^oc_sk_/.test(token)) return "api-key";
106
+ return "oauth";
107
+ }
108
+
109
+ /** Console metadata carried by an OpenCode credential grant. */
110
+ function grantMetadata(entry) {
111
+ return {
112
+ orgId: typeof entry?.metadata?.orgID === "string" ? entry.metadata.orgID : "",
113
+ server: typeof entry?.metadata?.server === "string" ? entry.metadata.server : ""
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Read the console OAuth token from OpenCode's auth.json.
119
+ * Only the `opencode` integration is a console grant; `opencode-go` is the GO
120
+ * API key and returns 403 on the console billing endpoint.
121
+ *
122
+ * Returns a candidate (with `kind`) or null. API-key entries are returned too,
123
+ * so the caller can explain that no console session exists instead of sending
124
+ * the key to an endpoint that only rejects it.
125
+ */
126
+ export function readOpencodeAuthJson(pathValue = "") {
127
+ const path = expandHome(pathValue) || defaultOpencodeAuthFile();
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(readFileSync(path, "utf8"));
131
+ } catch {
132
+ return null;
133
+ }
134
+ const entry = parsed?.opencode;
135
+ if (entry === null || entry === undefined) return null;
136
+ let token = null;
137
+ let type = null;
138
+ if (typeof entry === "string") {
139
+ token = entry.trim();
140
+ } else {
141
+ type = typeof entry.type === "string" ? entry.type : null;
142
+ if (typeof entry.access === "string" && entry.access.trim() !== "") {
143
+ token = entry.access.trim();
144
+ } else if (typeof entry.key === "string" && entry.key.trim() !== "") {
145
+ token = entry.key.trim();
146
+ }
147
+ }
148
+ if (token === null || token === "") return null;
149
+ const metadata = grantMetadata(entry);
150
+ return {
151
+ token,
152
+ kind: tokenKind(token, type),
153
+ orgId: metadata.orgId,
154
+ server: metadata.server,
155
+ expires: expiryMs(entry?.expires),
156
+ source: "opencode-auth-file",
157
+ path
158
+ };
159
+ }
160
+
161
+ /** Process-wide `node:sqlite` probe, resolved once per process. */
162
+ let sqliteDatabasePromise = null;
163
+
164
+ /** Resolve `node:sqlite`'s DatabaseSync, or null when it is unavailable. */
165
+ function sqliteDatabase() {
166
+ if (sqliteDatabasePromise === null) {
167
+ sqliteDatabasePromise = import("node:sqlite")
168
+ .then((module) => (typeof module.DatabaseSync === "function" ? module.DatabaseSync : null))
169
+ .catch(() => null);
170
+ }
171
+ return sqliteDatabasePromise;
172
+ }
173
+
174
+ /**
175
+ * The database file OpenCode uses.
176
+ *
177
+ * Explicit configuration wins; then `$OPENCODE_DB`; then the data directory's
178
+ * `opencode.db`, then a channel build (`opencode-beta.db`, `opencode-dev.db`,
179
+ * …). Current channels use the plain name, but a beta install stores a
180
+ * suffixed one and would otherwise look unauthenticated. A relative
181
+ * `$OPENCODE_DB` resolves against the search directory — the auth file's
182
+ * directory when `opencodeConsoleAuthFile` is set, else the data directory.
183
+ *
184
+ * @param {string} [pathValue] - explicit path (`opencodeConsoleDbFile`)
185
+ * @param {string} [fallbackDir] - directory to search when no path is set
186
+ */
187
+ export function resolveOpencodeDbFile(pathValue = "", fallbackDir = "") {
188
+ const explicit = expandHome(pathValue);
189
+ if (explicit !== "") return explicit;
190
+ const dir = fallbackDir !== "" ? fallbackDir : opencodeDataDir();
191
+ const fromEnv = typeof process.env.OPENCODE_DB === "string" ? process.env.OPENCODE_DB.trim() : "";
192
+ if (fromEnv !== "" && fromEnv !== ":memory:") return isAbsolute(fromEnv) ? fromEnv : join(dir, fromEnv);
193
+ const preferred = join(dir, "opencode.db");
194
+ try {
195
+ statSync(preferred);
196
+ return preferred;
197
+ } catch {
198
+ // fall through to a channel build
199
+ }
200
+ try {
201
+ const found = readdirSync(dir)
202
+ .filter((name) => /^opencode(?:-[A-Za-z0-9._-]+)?\.db$/.test(name))
203
+ .map((name) => {
204
+ const path = join(dir, name);
205
+ return { path, at: statSync(path).mtimeMs };
206
+ })
207
+ .sort((a, b) => b.at - a.at);
208
+ if (found.length > 0) return found[0].path;
209
+ } catch {
210
+ // no data directory: the read below fails and falls through to auth.json
211
+ }
212
+ return preferred;
213
+ }
214
+
215
+ /**
216
+ * Read every console grant from OpenCode's local SQLite database.
217
+ * Returns [] when node:sqlite is unavailable, the database is absent/locked,
218
+ * or the schema does not contain an `opencode` credential.
219
+ *
220
+ * @returns {Promise<Array<{ token: string, kind: string, orgId: string, server: string, expires: number | null, source: string, path: string }>>}
221
+ */
222
+ export async function readOpencodeDbCredentials(pathValue = "") {
223
+ const path = resolveOpencodeDbFile(pathValue);
224
+ const DatabaseSync = await sqliteDatabase();
225
+ if (DatabaseSync === null) return [];
226
+ let db;
227
+ try {
228
+ db = new DatabaseSync(path, { readOnly: true });
229
+ try {
230
+ db.exec("PRAGMA busy_timeout=1500");
231
+ } catch {
232
+ // a busy timeout is best-effort; the query still gets one try
233
+ }
234
+ // `active` and `time_created` were added over time; fall back to the
235
+ // plain table for very old databases. A query that runs is
236
+ // authoritative even when it returns no rows: an empty `active=1`
237
+ // result means logged out, not an old schema — the later queries
238
+ // drop the `active` filter and would resurrect a revoked grant.
239
+ const queries = [
240
+ "SELECT integration_id, value FROM credential WHERE active=1 AND integration_id='opencode' ORDER BY time_created DESC",
241
+ "SELECT integration_id, value FROM credential WHERE integration_id='opencode' ORDER BY time_created DESC",
242
+ "SELECT integration_id, value FROM credential WHERE integration_id='opencode'"
243
+ ];
244
+ let rows = null;
245
+ for (const query of queries) {
246
+ try {
247
+ rows = db.prepare(query).all();
248
+ // the schema works; an empty result is the real answer
249
+ break;
250
+ } catch {
251
+ // try the next, older schema
252
+ }
253
+ }
254
+ if (rows === null) return [];
255
+ const credentials = [];
256
+ for (const row of rows) {
257
+ if (row?.integration_id !== "opencode" || typeof row.value !== "string") continue;
258
+ let grant;
259
+ try {
260
+ grant = JSON.parse(row.value);
261
+ } catch {
262
+ continue;
263
+ }
264
+ const type = typeof grant?.type === "string" ? grant.type : null;
265
+ let token = null;
266
+ if (typeof grant?.access === "string" && grant.access.trim() !== "") {
267
+ token = grant.access.trim();
268
+ } else if (typeof grant?.key === "string" && grant.key.trim() !== "") {
269
+ token = grant.key.trim();
270
+ }
271
+ if (token === null) continue;
272
+ const metadata = grantMetadata(grant);
273
+ credentials.push({
274
+ token,
275
+ kind: tokenKind(token, type),
276
+ orgId: metadata.orgId,
277
+ server: metadata.server,
278
+ expires: expiryMs(grant?.expires),
279
+ source: "opencode-db",
280
+ path
281
+ });
282
+ }
283
+ return credentials;
284
+ } catch {
285
+ return [];
286
+ } finally {
287
+ try {
288
+ db?.close();
289
+ } catch {
290
+ // closing an already-closed handle is harmless
291
+ }
292
+ }
293
+ }
294
+
295
+ /** First console grant from OpenCode's database (kept for compatibility). */
296
+ export async function readOpencodeDbCredential(pathValue = "") {
297
+ const credentials = await readOpencodeDbCredentials(pathValue);
298
+ return credentials.find((credential) => credential.kind === "oauth") ?? credentials[0] ?? null;
299
+ }
300
+
301
+ /**
302
+ * Every usable console credential for a provider config.
303
+ *
304
+ * The database comes before the legacy auth file on purpose: OpenCode writes
305
+ * rotated grants to the database, and an old auth.json token would otherwise
306
+ * produce a 401 and hide the live balance. Duplicate tokens are collapsed and
307
+ * OAuth sessions are returned separately from API-key logins.
308
+ *
309
+ * @param {{
310
+ * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string, baseUrl?: string },
311
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
312
+ * }} input
313
+ * @returns {Promise<{
314
+ * credentials: Array<{ token: string, kind: string, orgId: string, server: string, expires: number | null, source: string, path: string | null }>,
315
+ * apiKey: { token: string, kind: string, orgId: string, server: string, expires: number | null, source: string, path: string | null } | null,
316
+ * }>}
317
+ */
318
+ export async function resolveOpencodeConsoleCredentials(input) {
319
+ const { config, resolveCredential } = input;
320
+ const explicitOrg = typeof config.orgId === "string" ? config.orgId.trim() : "";
321
+ const withOrg = (credential) => ({
322
+ ...credential,
323
+ orgId: explicitOrg !== "" ? explicitOrg : credential.orgId
324
+ });
325
+
326
+ const candidates = [];
327
+ const refs = uniqueStrings([config.apiKeyEnv, DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV]).filter(isCredentialRefName);
328
+ const credential = await resolveCredential(refs);
329
+ if (credential !== null && typeof credential.value === "string" && credential.value.trim() !== "") {
330
+ const token = credential.value.trim();
331
+ candidates.push(withOrg({
332
+ token,
333
+ kind: tokenKind(token, null),
334
+ orgId: "",
335
+ server: "",
336
+ expires: null,
337
+ source: `credential:${credential.ref}`,
338
+ path: null
339
+ }));
340
+ }
341
+
342
+ const authDir = typeof config.authFile === "string" && config.authFile.trim() !== ""
343
+ ? dirname(expandHome(config.authFile))
344
+ : "";
345
+ const dbFile = resolveOpencodeDbFile(config.dbFile, authDir);
346
+ for (const stored of await readOpencodeDbCredentials(dbFile)) candidates.push(withOrg(stored));
347
+
348
+ const authFile = readOpencodeAuthJson(config.authFile);
349
+ if (authFile !== null) candidates.push(withOrg(authFile));
350
+
351
+ const seen = new Set();
352
+ const unique = [];
353
+ for (const candidate of candidates) {
354
+ if (seen.has(candidate.token)) continue;
355
+ seen.add(candidate.token);
356
+ unique.push(candidate);
357
+ }
358
+ const credentials = unique.filter((candidate) => candidate.kind === "oauth");
359
+ const apiKey = unique.find((candidate) => candidate.kind !== "oauth") ?? null;
360
+ return { credentials, apiKey };
361
+ }
362
+
363
+ /**
364
+ * Resolve the preferred console credential for a provider config.
365
+ * @param {{
366
+ * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string, baseUrl?: string },
367
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
368
+ * }} input
369
+ */
370
+ export async function resolveOpencodeConsoleCredential(input) {
371
+ const { credentials } = await resolveOpencodeConsoleCredentials(input);
372
+ return credentials[0] ?? null;
373
+ }
374
+
375
+ /**
376
+ * Non-network detection report for the settings page.
377
+ * @returns {Promise<{ enabled: boolean, configured: boolean, source: string | null, orgId: string | null, error: string | null, reason: string | null, expired: boolean }>}
378
+ */
379
+ export async function opencodeConsoleStatus(input) {
380
+ const { config, resolveCredential } = input;
381
+ const enabled = config.enabled !== false;
382
+ if (!enabled) return { enabled: false, configured: false, source: null, orgId: null, error: null, reason: null, expired: false };
383
+ try {
384
+ const resolved = await resolveOpencodeConsoleCredentials({ config, resolveCredential });
385
+ const credential = resolved.credentials[0] ?? null;
386
+ if (credential === null) {
387
+ return {
388
+ enabled: true,
389
+ configured: false,
390
+ source: null,
391
+ orgId: typeof config.orgId === "string" && config.orgId.trim() !== "" ? config.orgId.trim() : null,
392
+ error: null,
393
+ reason: resolved.apiKey === null ? null : "api-key",
394
+ expired: false
395
+ };
396
+ }
397
+ const expired = typeof credential.expires === "number" && credential.expires <= Date.now();
398
+ return {
399
+ enabled: true,
400
+ configured: true,
401
+ source: credential.source,
402
+ orgId: credential.orgId || (typeof config.orgId === "string" && config.orgId.trim() !== "" ? config.orgId.trim() : null),
403
+ error: null,
404
+ reason: expired ? "expired" : null,
405
+ expired
406
+ };
407
+ } catch (error) {
408
+ return {
409
+ enabled: true,
410
+ configured: false,
411
+ source: null,
412
+ orgId: null,
413
+ error: error instanceof Error ? error.message : String(error),
414
+ reason: null,
415
+ expired: false
416
+ };
417
+ }
418
+ }
419
+
420
+ /** Pick the first finite number from `values` (numeric strings accepted). */
421
+ function finite(values) {
422
+ for (const value of values) {
423
+ const number = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
424
+ if (Number.isFinite(number)) return number;
425
+ }
426
+ return null;
427
+ }
428
+
429
+ /**
430
+ * The console origin for a credential: an explicit override wins, then the
431
+ * server recorded in the grant (`https://opencode.ai/console` or a
432
+ * self-hosted console), then the public default.
433
+ */
434
+ function consoleBaseUrl(config, credential) {
435
+ const configured = typeof config.baseUrl === "string" ? config.baseUrl.trim().replace(/\/+$/, "") : "";
436
+ // The config carries the public default even when the user never touched it,
437
+ // so an explicit non-default override wins while the default still lets a
438
+ // self-hosted `metadata.server` grant pick its own origin.
439
+ if (configured !== "" && configured !== DEFAULT_OPENCODE_CONSOLE_BASE_URL) return configured;
440
+ const server = typeof credential.server === "string" && credential.server.trim() !== ""
441
+ ? credential.server.trim()
442
+ : DEFAULT_OPENCODE_CONSOLE_BASE_URL;
443
+ try {
444
+ return new URL(server).origin;
445
+ } catch {
446
+ return DEFAULT_OPENCODE_CONSOLE_BASE_URL;
447
+ }
448
+ }
449
+
450
+ /** Fetch and normalize the billing status for one credential. */
451
+ async function fetchOpencodeBalance(config, credential, fetchImpl) {
452
+ const baseUrl = consoleBaseUrl(config, credential);
453
+ const headers = {
454
+ Accept: "application/json",
455
+ Authorization: `Bearer ${credential.token}`
456
+ };
457
+ if (typeof credential.orgId === "string" && credential.orgId.length > 0) {
458
+ headers["x-org-id"] = credential.orgId;
459
+ }
460
+ let res;
461
+ try {
462
+ res = await fetchImpl(`${baseUrl}/console/api/billing/status`, {
463
+ headers,
464
+ signal: AbortSignal.timeout(8_000)
465
+ });
466
+ } catch (error) {
467
+ throw new ConsoleError("network-error", `OpenCode balance: ${error instanceof Error ? error.message : String(error)}`);
468
+ }
469
+ if (!res.ok) {
470
+ const body = await res.text().catch(() => "");
471
+ if (res.status === 401 || res.status === 403) {
472
+ throw new ConsoleError(
473
+ "console-unauthorized",
474
+ `OpenCode balance ${res.status}: the saved OpenCode Console session is expired or not a Console login — run \`opencode auth login\` again (or set OPENCODE_CONSOLE_TOKEN) [source: ${credential.source}]`,
475
+ res.status
476
+ );
477
+ }
478
+ throw new ConsoleError("http-error", `OpenCode balance ${res.status}: ${body.slice(0, 300)}`, res.status);
479
+ }
480
+ let payload;
481
+ try {
482
+ payload = await res.json();
483
+ } catch {
484
+ throw new ConsoleError("invalid-response", "OpenCode balance: response is not JSON");
485
+ }
486
+ const microCents = finite([payload?.availableMicroCents, payload?.balanceMicroCents]);
487
+ const cents = finite([payload?.availableCents, payload?.balanceCents]);
488
+ const direct = finite([payload?.balance, payload?.available]);
489
+ const amount = microCents !== null ? microCents / 100_000_000 : cents !== null ? cents / 100 : direct;
490
+ if (amount === null) {
491
+ throw new ConsoleError("invalid-response", "OpenCode balance: response has no numeric balance");
492
+ }
493
+ return {
494
+ amount,
495
+ currency: typeof payload?.currency === "string" && payload.currency.length > 0 ? payload.currency : "USD",
496
+ labelKey: "balance.zen",
497
+ source: credential.source,
498
+ details: {
499
+ billingMode: typeof payload?.billingMode === "string" ? payload.billingMode : null,
500
+ mode: typeof payload?.mode === "string" ? payload.mode : null,
501
+ canPurchaseCredits: payload?.canPurchaseCredits === true,
502
+ canEnableAutoRecharge: payload?.canEnableAutoRecharge === true,
503
+ microCents: microCents
504
+ }
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Fetch the OpenCode console billing status and normalize the prepaid balance.
510
+ *
511
+ * `balanceMicroCents` and `availableMicroCents` are 1/100,000,000 USD.
512
+ *
513
+ * Every discovered OAuth session is tried (newest storage first); a stale
514
+ * legacy `auth.json` token therefore cannot hide a live database grant. At
515
+ * most four candidates are tried before the first authentication failure is
516
+ * reported. When only an API-key login exists, the error says so instead of
517
+ * forwarding the key to an endpoint that rejects it.
518
+ *
519
+ * @param {{
520
+ * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string, baseUrl?: string },
521
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
522
+ * fetchImpl?: typeof fetch,
523
+ * }} input
524
+ */
525
+ export async function readOpencodeBalance(input) {
526
+ const { config, resolveCredential, fetchImpl = fetch } = input;
527
+ const { credentials, apiKey } = await resolveOpencodeConsoleCredentials({ config, resolveCredential });
528
+ if (credentials.length === 0) {
529
+ if (apiKey !== null) {
530
+ throw new ConsoleError(
531
+ "console-api-key",
532
+ "OpenCode balance: the local OpenCode login is an API key, not an OpenCode Console session — run `opencode auth login` and choose the OpenCode Console account (or set OPENCODE_CONSOLE_TOKEN)"
533
+ );
534
+ }
535
+ throw new ConsoleError(
536
+ "no-console-credential",
537
+ "OpenCode balance: no console credential (set OPENCODE_CONSOLE_TOKEN, or sign in with OpenCode)"
538
+ );
539
+ }
540
+ const failures = [];
541
+ for (const credential of credentials.slice(0, 4)) {
542
+ try {
543
+ return await fetchOpencodeBalance(config, credential, fetchImpl);
544
+ } catch (error) {
545
+ const failure = error instanceof Error ? error : new Error(String(error));
546
+ failures.push(failure);
547
+ const status = failure instanceof ConsoleError ? failure.status : null;
548
+ // Only an authentication failure is worth retrying on the next
549
+ // candidate. A network or schema failure is shared by all sources
550
+ // (and a retry would only multiply the wait).
551
+ if (status !== 401 && status !== 403) break;
552
+ }
553
+ }
554
+ const unauthorized = failures.find((failure) => failure instanceof ConsoleError && (failure.status === 401 || failure.status === 403));
555
+ throw unauthorized ?? failures[0];
556
+ }
@@ -0,0 +1,139 @@
1
+ // @ts-check
2
+ /**
3
+ * dsh-multi-provider-usage — runtime `x-opencode-session` injection (host half).
4
+ *
5
+ * The OpenCode GO gateway rejects chat-completion requests without the
6
+ * `x-opencode-session` header (HTTP 400 `{"type":"MissingSessionID"}`), and
7
+ * DSH's `llm-pi-ai` adapter never sends it. Instead of patching DSH's installed
8
+ * files (wiped by every upgrade), this module injects the header at runtime:
9
+ *
10
+ * 1. `patchFetch()` wraps `globalThis.fetch` once. A request receives the
11
+ * header only when the AsyncLocalStorage context carries a session AND the
12
+ * request URL starts with that call's gateway base.
13
+ * 2. `withSession()` runs a stream inside that context. Standing on DSH's
14
+ * official `llm/stream` waterfall event, the host captures the per-call
15
+ * `options.sessionId` and resolves the wire base from
16
+ * `llm-pi-ai.providers.<route>.baseURL`, falling back to this plugin's
17
+ * configured OpenCode GO base.
18
+ *
19
+ * The header value is the real per-conversation harness session id — never a
20
+ * fixed fake — and only requests to the configured gateway base of the call
21
+ * being streamed ever receive it.
22
+ */
23
+
24
+ import { AsyncLocalStorage } from "node:async_hooks";
25
+
26
+ /** Marks `globalThis.fetch` as already wrapped by this module (HMR-safe). */
27
+ const FETCH_WRAPPED = Symbol.for("dsh-multi-provider-usage:fetch-wrapped");
28
+
29
+ /**
30
+ * Per-process AsyncLocalStorage bridging the LLM call to the wire fetch.
31
+ * Store shape: `{ sessionId: string, base: string }`.
32
+ */
33
+ const sessionStore = new AsyncLocalStorage();
34
+
35
+ /** How many OpenCode gateway requests actually received the session header. */
36
+ let injectionCount = 0;
37
+
38
+ /**
39
+ * Runtime observability: the waterfall handler records every `llm/stream` it
40
+ * sees, and the fetch wrapper counts wire calls that carried the context
41
+ * (`requests`), got the header (`injected`), or fell through because the URL
42
+ * did not match that call's base (`missed`).
43
+ */
44
+ const diag = {
45
+ streamSeen: 0,
46
+ /** @type {{ at: number, provider: string | null, hasSessionId: boolean, base: string | null, baseFrom: "settings" | "config" | null } | null} */
47
+ lastStream: null,
48
+ requests: 0,
49
+ injected: 0,
50
+ missed: 0
51
+ };
52
+
53
+ /** @returns {number} count of requests that got `x-opencode-session`. */
54
+ export function sessionHeaderCount() {
55
+ return injectionCount;
56
+ }
57
+
58
+ /** Record one `llm/stream` observation. */
59
+ export function recordStream(info) {
60
+ diag.streamSeen += 1;
61
+ diag.lastStream = { at: Date.now(), ...info };
62
+ }
63
+
64
+ /** Snapshot of the runtime diagnostics (safe for JSON responses). */
65
+ export function sessionDiag() {
66
+ return { ...diag, lastStream: diag.lastStream === null ? null : { ...diag.lastStream } };
67
+ }
68
+
69
+ /**
70
+ * Wrap `globalThis.fetch` once. Requests whose URL starts with the gateway
71
+ * base carried in the ALS context get `x-opencode-session` added. Idempotent.
72
+ */
73
+ export function patchFetch() {
74
+ if (globalThis[FETCH_WRAPPED]) return;
75
+ const nativeFetch = globalThis.fetch;
76
+ globalThis[FETCH_WRAPPED] = true;
77
+ globalThis.fetch = function sessionFetch(input, init) {
78
+ const meta = sessionStore.getStore();
79
+ if (meta?.sessionId !== undefined) {
80
+ if (matchesBase(input, meta.base)) {
81
+ // When the caller passed a Request, its own headers are the base:
82
+ // per the fetch spec an `init.headers` would otherwise replace them
83
+ // wholesale and drop e.g. Authorization.
84
+ const base = init?.headers ?? (input !== null && typeof input === "object" && input.headers !== null && typeof input.headers === "object" ? input.headers : undefined);
85
+ const headers = new Headers(base);
86
+ if (!headers.has("x-opencode-session")) {
87
+ headers.set("x-opencode-session", String(meta.sessionId));
88
+ injectionCount += 1;
89
+ }
90
+ init = { ...init, headers };
91
+ diag.injected += 1;
92
+ } else {
93
+ diag.missed += 1;
94
+ }
95
+ diag.requests += 1;
96
+ }
97
+ return nativeFetch.call(this, input, init);
98
+ };
99
+ }
100
+
101
+ /**
102
+ * True when the request URL targets the given gateway base (trailing slashes
103
+ * ignored; the base itself or `base/...` both match).
104
+ */
105
+ function matchesBase(input, base) {
106
+ const url = typeof input === "string"
107
+ ? input
108
+ : input instanceof URL
109
+ ? input.href
110
+ : typeof input?.url === "string" ? input.url : String(input);
111
+ const normalized = String(base ?? "").replace(/\/+$/, "");
112
+ return normalized.length > 0 && (url === normalized || url.startsWith(normalized + "/"));
113
+ }
114
+
115
+ /**
116
+ * Run a stream's iteration inside the given session context, so async work
117
+ * created while the adapter pulls chunks resolves the context from the ALS
118
+ * store. Implements the async-iterator protocol.
119
+ * @template T
120
+ * @param {AsyncIterable<T>} stream
121
+ * @param {{ sessionId: string, base: string }} meta
122
+ * @returns {AsyncIterable<T>}
123
+ */
124
+ export function withSession(stream, meta) {
125
+ const iterator = stream[Symbol.asyncIterator]();
126
+ const run = (method, args) => {
127
+ const fn = iterator[method];
128
+ if (typeof fn !== "function") {
129
+ return Promise.resolve(method === "throw" ? { done: true } : void 0);
130
+ }
131
+ return sessionStore.run(meta, () => Promise.resolve(fn.call(iterator, ...args)));
132
+ };
133
+ return {
134
+ [Symbol.asyncIterator]() { return this; },
135
+ next: (...args) => run("next", args),
136
+ return: (...args) => run("return", args),
137
+ throw: (...args) => run("throw", args)
138
+ };
139
+ }