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.
- package/LICENSE +21 -0
- package/README.md +427 -2
- package/README.zh.md +377 -0
- package/cordis.patch.yml +15 -0
- package/docs/widget.png +0 -0
- package/lib/auto-update.js +570 -0
- package/lib/client.js +2226 -0
- package/lib/index.js +1290 -0
- package/lib/opencode-console.js +556 -0
- package/lib/opencode-session.js +139 -0
- package/lib/providers.js +919 -0
- package/lib/util.js +27 -0
- package/package.json +64 -4
|
@@ -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
|
+
}
|