dsh-multi-provider-usage 0.7.1 → 0.7.3
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/README.md +77 -28
- package/README.zh.md +60 -23
- package/lib/auto-update.js +188 -26
- package/lib/client.js +544 -69
- package/lib/index.js +13 -1
- package/lib/opencode-console.js +329 -94
- package/lib/opencode-session.js +5 -1
- package/lib/providers.js +24 -7
- package/package.json +1 -1
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
|
-
|
|
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");
|
package/lib/opencode-console.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
* 3. OpenCode's
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
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`
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
121
|
-
const
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
272
|
+
const metadata = grantMetadata(grant);
|
|
273
|
+
credentials.push({
|
|
148
274
|
token,
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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:
|
|
230
|
-
source: credential
|
|
231
|
-
orgId: credential
|
|
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
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/opencode-session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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: {
|
|
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.
|
|
3
|
+
"version": "0.7.3",
|
|
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",
|