dsh-multi-provider-usage 0.7.1 → 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.
package/lib/index.js CHANGED
@@ -698,7 +698,19 @@ function renderUsageText(data, lang) {
698
698
  } else if (provider.meta?.balanceMissing === true) {
699
699
  lines.push(zh ? " 订阅外充值余额: 无充值额度" : " Top-up balance: no top-up credits");
700
700
  } else if (typeof provider.meta?.balanceError === "string" && provider.meta.balanceError !== "") {
701
- if (/no console credential/i.test(provider.meta.balanceError)) {
701
+ // Each fixable setup state names its fix explicitly; the regex
702
+ // fallback keeps older host payloads (which carry only the message)
703
+ // working.
704
+ const setup = typeof provider.meta.balanceSetup === "string" ? provider.meta.balanceSetup : "";
705
+ if (setup === "api-key") {
706
+ lines.push(zh
707
+ ? " 订阅外充值余额: 本机是 API Key 登录,不含余额 — 运行 opencode auth login 并选择 OpenCode Console 账号"
708
+ : " Top-up balance: the local login is an API key and carries no balance — run opencode auth login and choose the OpenCode Console account");
709
+ } else if (setup === "expired") {
710
+ lines.push(zh
711
+ ? " 订阅外充值余额: 控制台登录已过期 — 重新运行 opencode auth login 后再刷新"
712
+ : " Top-up balance: the console sign-in expired — run opencode auth login again and refresh");
713
+ } else if (/no console credential/i.test(provider.meta.balanceError)) {
702
714
  lines.push(zh
703
715
  ? " 订阅外充值余额: 未配置 — 运行 opencode auth login 或设置 OPENCODE_CONSOLE_TOKEN"
704
716
  : " Top-up balance: not configured — run opencode auth login or set OPENCODE_CONSOLE_TOKEN");
@@ -13,10 +13,18 @@
13
13
  * API key. This module resolves that token, in priority order:
14
14
  *
15
15
  * 1. a DSH credential ref (`opencodeConsoleApiKeyEnv`);
16
- * 2. OpenCode's `auth.json` (`~/.local/share/opencode/auth.json`, or
17
- * `$XDG_DATA_HOME/opencode/auth.json`);
18
- * 3. OpenCode's local database (`opencode.db`, `credential` table), which is
19
- * where current OpenCode releases keep the OAuth grant.
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.
20
28
  *
21
29
  * Reading the local OpenCode store is optional and can be disabled with
22
30
  * `opencodeConsoleEnabled: false`. Values never leave the host process; the
@@ -25,9 +33,9 @@
25
33
  * and a runtime without it simply falls through to the auth file.
26
34
  */
27
35
 
28
- import { readFileSync } from "node:fs";
36
+ import { readFileSync, readdirSync, statSync } from "node:fs";
29
37
  import { homedir } from "node:os";
30
- import { dirname, join } from "node:path";
38
+ import { dirname, isAbsolute, join } from "node:path";
31
39
  import { isCredentialRefName, uniqueStrings } from "./util.js";
32
40
 
33
41
  /** Default credential ref for a manually supplied console token. */
@@ -35,12 +43,16 @@ export const DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV = "OPENCODE_CONSOLE_TOKEN";
35
43
  /** Default OpenCode console origin. */
36
44
  export const DEFAULT_OPENCODE_CONSOLE_BASE_URL = "https://opencode.ai";
37
45
 
38
- /** A balance read failure with a stable code (no token in the message). */
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
+ */
39
50
  export class ConsoleError extends Error {
40
- constructor(code, message) {
51
+ constructor(code, message, status = null) {
41
52
  super(message);
42
53
  this.name = "ConsoleError";
43
54
  this.code = code;
55
+ this.status = status;
44
56
  }
45
57
  }
46
58
 
@@ -69,10 +81,47 @@ function expandHome(pathValue) {
69
81
  return raw;
70
82
  }
71
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
+
72
117
  /**
73
118
  * Read the console OAuth token from OpenCode's auth.json.
74
- * Only the `opencode` provider is a console grant; `opencode-go` is the GO API
75
- * key and returns 403 on the console billing endpoint.
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.
76
125
  */
77
126
  export function readOpencodeAuthJson(pathValue = "") {
78
127
  const path = expandHome(pathValue) || defaultOpencodeAuthFile();
@@ -84,16 +133,26 @@ export function readOpencodeAuthJson(pathValue = "") {
84
133
  }
85
134
  const entry = parsed?.opencode;
86
135
  if (entry === null || entry === undefined) return null;
87
- const token = typeof entry === "string"
88
- ? entry
89
- : typeof entry.access === "string" && entry.access.length > 0
90
- ? entry.access
91
- : typeof entry.key === "string" && entry.key.length > 0 ? entry.key : null;
92
- if (token === null) 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);
93
150
  return {
94
151
  token,
95
- orgId: typeof entry?.metadata?.orgID === "string" ? entry.metadata.orgID : "",
96
- expires: typeof entry?.expires === "number" ? entry.expires : null,
152
+ kind: tokenKind(token, type),
153
+ orgId: metadata.orgId,
154
+ server: metadata.server,
155
+ expires: expiryMs(entry?.expires),
97
156
  source: "opencode-auth-file",
98
157
  path
99
158
  };
@@ -113,14 +172,57 @@ function sqliteDatabase() {
113
172
  }
114
173
 
115
174
  /**
116
- * Read the console OAuth token from OpenCode's local SQLite database.
117
- * Returns null when node:sqlite is unavailable, the database is absent/locked,
118
- * or the schema does not contain an active `opencode` credential.
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
119
186
  */
120
- export async function readOpencodeDbCredential(pathValue = "") {
121
- const path = expandHome(pathValue) || defaultOpencodeDbFile();
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);
122
224
  const DatabaseSync = await sqliteDatabase();
123
- if (DatabaseSync === null) return null;
225
+ if (DatabaseSync === null) return [];
124
226
  let db;
125
227
  try {
126
228
  db = new DatabaseSync(path, { readOnly: true });
@@ -129,9 +231,28 @@ export async function readOpencodeDbCredential(pathValue = "") {
129
231
  } catch {
130
232
  // a busy timeout is best-effort; the query still gets one try
131
233
  }
132
- const rows = db.prepare(
133
- "SELECT integration_id, value FROM credential WHERE active=1 AND integration_id IN ('opencode','opencode-go')"
134
- ).all();
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 = [];
135
256
  for (const row of rows) {
136
257
  if (row?.integration_id !== "opencode" || typeof row.value !== "string") continue;
137
258
  let grant;
@@ -140,20 +261,28 @@ export async function readOpencodeDbCredential(pathValue = "") {
140
261
  } catch {
141
262
  continue;
142
263
  }
143
- const token = typeof grant?.access === "string" && grant.access.length > 0
144
- ? grant.access
145
- : typeof grant?.key === "string" && grant.key.length > 0 ? grant.key : null;
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
+ }
146
271
  if (token === null) continue;
147
- return {
272
+ const metadata = grantMetadata(grant);
273
+ credentials.push({
148
274
  token,
149
- orgId: typeof grant?.metadata?.orgID === "string" ? grant.metadata.orgID : "",
150
- expires: typeof grant?.expires === "number" ? grant.expires : null,
275
+ kind: tokenKind(token, type),
276
+ orgId: metadata.orgId,
277
+ server: metadata.server,
278
+ expires: expiryMs(grant?.expires),
151
279
  source: "opencode-db",
152
280
  path
153
- };
281
+ });
154
282
  }
283
+ return credentials;
155
284
  } catch {
156
- return null;
285
+ return [];
157
286
  } finally {
158
287
  try {
159
288
  db?.close();
@@ -161,75 +290,119 @@ export async function readOpencodeDbCredential(pathValue = "") {
161
290
  // closing an already-closed handle is harmless
162
291
  }
163
292
  }
164
- return null;
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;
165
299
  }
166
300
 
167
301
  /**
168
- * Resolve the console credential for a provider config.
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.
169
308
  *
170
- * `apiKeyEnv` is tried first through DSH credentials; then the OpenCode auth
171
- * file and database. `orgId` from config overrides the metadata value.
172
309
  * @param {{
173
- * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string },
310
+ * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string, baseUrl?: string },
174
311
  * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
175
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
+ * }>}
176
317
  */
177
- export async function resolveOpencodeConsoleCredential(input) {
318
+ export async function resolveOpencodeConsoleCredentials(input) {
178
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 = [];
179
327
  const refs = uniqueStrings([config.apiKeyEnv, DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV]).filter(isCredentialRefName);
180
328
  const credential = await resolveCredential(refs);
181
- if (credential !== null) {
182
- return {
183
- token: credential.value,
184
- orgId: typeof config.orgId === "string" ? config.orgId.trim() : "",
185
- source: `credential:${credential.ref}`,
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: "",
186
336
  expires: null,
337
+ source: `credential:${credential.ref}`,
187
338
  path: null
188
- };
339
+ }));
189
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
+
190
348
  const authFile = readOpencodeAuthJson(config.authFile);
191
- if (authFile !== null) {
192
- return {
193
- ...authFile,
194
- orgId: typeof config.orgId === "string" && config.orgId.trim().length > 0 ? config.orgId.trim() : authFile.orgId
195
- };
196
- }
197
- const dbFile = resolveDbPath(config);
198
- const db = await readOpencodeDbCredential(dbFile);
199
- if (db !== null) {
200
- return {
201
- ...db,
202
- orgId: typeof config.orgId === "string" && config.orgId.trim().length > 0 ? config.orgId.trim() : db.orgId
203
- };
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);
204
357
  }
205
- return null;
358
+ const credentials = unique.filter((candidate) => candidate.kind === "oauth");
359
+ const apiKey = unique.find((candidate) => candidate.kind !== "oauth") ?? null;
360
+ return { credentials, apiKey };
206
361
  }
207
362
 
208
- /** Derive the database path, preferring one next to an explicit auth file. */
209
- function resolveDbPath(config) {
210
- const explicit = expandHome(config.dbFile);
211
- if (explicit.length > 0) return explicit;
212
- const authFile = expandHome(config.authFile);
213
- if (authFile.length > 0) return join(dirname(authFile), "opencode.db");
214
- return defaultOpencodeDbFile();
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;
215
373
  }
216
374
 
217
375
  /**
218
376
  * Non-network detection report for the settings page.
219
- * @returns {Promise<{ enabled: boolean, configured: boolean, source: string | null, orgId: string | null, error: string | null }>}
377
+ * @returns {Promise<{ enabled: boolean, configured: boolean, source: string | null, orgId: string | null, error: string | null, reason: string | null, expired: boolean }>}
220
378
  */
221
379
  export async function opencodeConsoleStatus(input) {
222
380
  const { config, resolveCredential } = input;
223
381
  const enabled = config.enabled !== false;
224
- if (!enabled) return { enabled: false, configured: false, source: null, orgId: null, error: null };
382
+ if (!enabled) return { enabled: false, configured: false, source: null, orgId: null, error: null, reason: null, expired: false };
225
383
  try {
226
- const credential = await resolveOpencodeConsoleCredential({ config, resolveCredential });
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();
227
398
  return {
228
399
  enabled: true,
229
- configured: credential !== null,
230
- source: credential?.source ?? null,
231
- orgId: credential?.orgId ?? (typeof config.orgId === "string" && config.orgId.trim().length > 0 ? config.orgId.trim() : null),
232
- error: null
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
233
406
  };
234
407
  } catch (error) {
235
408
  return {
@@ -237,7 +410,9 @@ export async function opencodeConsoleStatus(input) {
237
410
  configured: false,
238
411
  source: null,
239
412
  orgId: null,
240
- error: error instanceof Error ? error.message : String(error)
413
+ error: error instanceof Error ? error.message : String(error),
414
+ reason: null,
415
+ expired: false
241
416
  };
242
417
  }
243
418
  }
@@ -252,25 +427,29 @@ function finite(values) {
252
427
  }
253
428
 
254
429
  /**
255
- * Fetch the OpenCode console billing status and normalize the prepaid balance.
256
- *
257
- * `balanceMicroCents` and `availableMicroCents` are 1/100,000,000 USD.
258
- * @param {{
259
- * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string, baseUrl?: string },
260
- * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
261
- * fetchImpl?: typeof fetch,
262
- * }} input
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.
263
433
  */
264
- export async function readOpencodeBalance(input) {
265
- const { config, resolveCredential, fetchImpl = fetch } = input;
266
- const credential = await resolveOpencodeConsoleCredential({ config, resolveCredential });
267
- if (credential === null) {
268
- throw new ConsoleError(
269
- "no-console-credential",
270
- "OpenCode balance: no console credential (set OPENCODE_CONSOLE_TOKEN, or sign in with OpenCode)"
271
- );
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;
272
447
  }
273
- const baseUrl = String(config.baseUrl || DEFAULT_OPENCODE_CONSOLE_BASE_URL).replace(/\/+$/, "");
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);
274
453
  const headers = {
275
454
  Accept: "application/json",
276
455
  Authorization: `Bearer ${credential.token}`
@@ -289,7 +468,14 @@ export async function readOpencodeBalance(input) {
289
468
  }
290
469
  if (!res.ok) {
291
470
  const body = await res.text().catch(() => "");
292
- throw new ConsoleError("http-error", `OpenCode balance ${res.status}: ${body.slice(0, 300)}`);
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);
293
479
  }
294
480
  let payload;
295
481
  try {
@@ -319,3 +505,52 @@ export async function readOpencodeBalance(input) {
319
505
  };
320
506
  }
321
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
+ }
@@ -78,7 +78,11 @@ export function patchFetch() {
78
78
  const meta = sessionStore.getStore();
79
79
  if (meta?.sessionId !== undefined) {
80
80
  if (matchesBase(input, meta.base)) {
81
- const headers = new Headers(init?.headers);
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);
82
86
  if (!headers.has("x-opencode-session")) {
83
87
  headers.set("x-opencode-session", String(meta.sessionId));
84
88
  injectionCount += 1;
package/lib/providers.js CHANGED
@@ -235,13 +235,26 @@ function startOpencodeBalance(consoleConfig, resolveCredential, fetchImpl) {
235
235
  .catch((error) => ({ ok: false, error }));
236
236
  }
237
237
 
238
- /** Project a settled balance read into public result fields. */
238
+ /**
239
+ * Project a settled balance read into public result fields.
240
+ *
241
+ * A failed read carries `balanceSetup` when the user can act on it (no console
242
+ * login, an API-key-only login, or an expired/revoked session); the widget
243
+ * turns that into a setup chip instead of a mute dash.
244
+ */
239
245
  function balanceFields(settled) {
240
- if (settled === null) return { balance: null, balanceError: null };
241
- if (settled.ok) return { balance: settled.balance, balanceError: null };
246
+ if (settled === null) return { balance: null, balanceError: null, balanceSetup: null };
247
+ if (settled.ok) return { balance: settled.balance, balanceError: null, balanceSetup: null };
248
+ const error = settled.error;
249
+ const code = error?.code;
242
250
  return {
243
251
  balance: null,
244
- balanceError: settled.error instanceof Error ? settled.error.message : String(settled.error)
252
+ balanceError: error instanceof Error ? error.message : String(error),
253
+ balanceSetup: code === "no-console-credential"
254
+ ? "no-credential"
255
+ : code === "console-api-key"
256
+ ? "api-key"
257
+ : code === "console-unauthorized" ? "expired" : null
245
258
  };
246
259
  }
247
260
 
@@ -253,7 +266,8 @@ function attachBalance(result, settled) {
253
266
  balance: fields.balance,
254
267
  meta: {
255
268
  ...(result.meta ?? {}),
256
- ...(fields.balanceError === null ? {} : { balanceError: fields.balanceError })
269
+ ...(fields.balanceError === null ? {} : { balanceError: fields.balanceError }),
270
+ ...(fields.balanceSetup === null ? {} : { balanceSetup: fields.balanceSetup })
257
271
  }
258
272
  };
259
273
  }
@@ -269,7 +283,11 @@ async function opencodeGoFailure(code, message, accountRef, probe, fallback, bal
269
283
  account: { ref: accountRef, id: null, name: "" },
270
284
  fallback,
271
285
  balance: balance.balance,
272
- meta: { opencodeGo: probe, ...(balance.balanceError === null ? {} : { balanceError: balance.balanceError }) }
286
+ meta: {
287
+ opencodeGo: probe,
288
+ ...(balance.balanceError === null ? {} : { balanceError: balance.balanceError }),
289
+ ...(balance.balanceSetup === null ? {} : { balanceSetup: balance.balanceSetup })
290
+ }
273
291
  });
274
292
  }
275
293
 
@@ -899,4 +917,3 @@ export async function readGlm(input) {
899
917
  account: { ref: credential.ref, id: null, name: "" }
900
918
  });
901
919
  }
902
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-provider-usage",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "DSH (DeepSeek Harness) plugin: one collapsible sidebar widget for OpenCode GO, Command Code and GLM Code Plan quotas plus top-up balances, a periodic balance-change animation, automatic checkout and installed-package updates, same-origin usage proxies, a Plugins-panel settings page and a /provider-usage chat command (DSH 0.1.7+ and 0.2.0 compatible)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",