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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
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 `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.
20
+ *
21
+ * Reading the local OpenCode store is optional and can be disabled with
22
+ * `opencodeConsoleEnabled: false`. Values never leave the host process; the
23
+ * settings/detection payload reports only the source, org id and whether a
24
+ * credential was found. `node:sqlite` is used opportunistically (Node 22.5+)
25
+ * and a runtime without it simply falls through to the auth file.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { dirname, join } from "node:path";
31
+ import { isCredentialRefName, uniqueStrings } from "./util.js";
32
+
33
+ /** Default credential ref for a manually supplied console token. */
34
+ export const DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV = "OPENCODE_CONSOLE_TOKEN";
35
+ /** Default OpenCode console origin. */
36
+ export const DEFAULT_OPENCODE_CONSOLE_BASE_URL = "https://opencode.ai";
37
+
38
+ /** A balance read failure with a stable code (no token in the message). */
39
+ export class ConsoleError extends Error {
40
+ constructor(code, message) {
41
+ super(message);
42
+ this.name = "ConsoleError";
43
+ this.code = code;
44
+ }
45
+ }
46
+
47
+ /** The OpenCode data directory, honouring XDG_DATA_HOME. */
48
+ export function opencodeDataDir(env = process.env) {
49
+ const xdg = typeof env.XDG_DATA_HOME === "string" ? env.XDG_DATA_HOME.trim() : "";
50
+ return xdg.length > 0 ? join(xdg, "opencode") : join(homedir(), ".local", "share", "opencode");
51
+ }
52
+
53
+ /** Default OpenCode `auth.json` location. */
54
+ export function defaultOpencodeAuthFile(env = process.env) {
55
+ return join(opencodeDataDir(env), "auth.json");
56
+ }
57
+
58
+ /** Default OpenCode `opencode.db` location. */
59
+ export function defaultOpencodeDbFile(env = process.env) {
60
+ return join(opencodeDataDir(env), "opencode.db");
61
+ }
62
+
63
+ /** Expand `~` in a user-supplied path. */
64
+ function expandHome(pathValue) {
65
+ const raw = String(pathValue ?? "").trim();
66
+ if (raw === "") return "";
67
+ if (raw === "~") return homedir();
68
+ if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
69
+ return raw;
70
+ }
71
+
72
+ /**
73
+ * 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.
76
+ */
77
+ export function readOpencodeAuthJson(pathValue = "") {
78
+ const path = expandHome(pathValue) || defaultOpencodeAuthFile();
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(path, "utf8"));
82
+ } catch {
83
+ return null;
84
+ }
85
+ const entry = parsed?.opencode;
86
+ 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;
93
+ return {
94
+ token,
95
+ orgId: typeof entry?.metadata?.orgID === "string" ? entry.metadata.orgID : "",
96
+ expires: typeof entry?.expires === "number" ? entry.expires : null,
97
+ source: "opencode-auth-file",
98
+ path
99
+ };
100
+ }
101
+
102
+ /** Process-wide `node:sqlite` probe, resolved once per process. */
103
+ let sqliteDatabasePromise = null;
104
+
105
+ /** Resolve `node:sqlite`'s DatabaseSync, or null when it is unavailable. */
106
+ function sqliteDatabase() {
107
+ if (sqliteDatabasePromise === null) {
108
+ sqliteDatabasePromise = import("node:sqlite")
109
+ .then((module) => (typeof module.DatabaseSync === "function" ? module.DatabaseSync : null))
110
+ .catch(() => null);
111
+ }
112
+ return sqliteDatabasePromise;
113
+ }
114
+
115
+ /**
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.
119
+ */
120
+ export async function readOpencodeDbCredential(pathValue = "") {
121
+ const path = expandHome(pathValue) || defaultOpencodeDbFile();
122
+ const DatabaseSync = await sqliteDatabase();
123
+ if (DatabaseSync === null) return null;
124
+ let db;
125
+ try {
126
+ db = new DatabaseSync(path, { readOnly: true });
127
+ try {
128
+ db.exec("PRAGMA busy_timeout=1500");
129
+ } catch {
130
+ // a busy timeout is best-effort; the query still gets one try
131
+ }
132
+ const rows = db.prepare(
133
+ "SELECT integration_id, value FROM credential WHERE active=1 AND integration_id IN ('opencode','opencode-go')"
134
+ ).all();
135
+ for (const row of rows) {
136
+ if (row?.integration_id !== "opencode" || typeof row.value !== "string") continue;
137
+ let grant;
138
+ try {
139
+ grant = JSON.parse(row.value);
140
+ } catch {
141
+ continue;
142
+ }
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;
146
+ if (token === null) continue;
147
+ return {
148
+ token,
149
+ orgId: typeof grant?.metadata?.orgID === "string" ? grant.metadata.orgID : "",
150
+ expires: typeof grant?.expires === "number" ? grant.expires : null,
151
+ source: "opencode-db",
152
+ path
153
+ };
154
+ }
155
+ } catch {
156
+ return null;
157
+ } finally {
158
+ try {
159
+ db?.close();
160
+ } catch {
161
+ // closing an already-closed handle is harmless
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Resolve the console credential for a provider config.
169
+ *
170
+ * `apiKeyEnv` is tried first through DSH credentials; then the OpenCode auth
171
+ * file and database. `orgId` from config overrides the metadata value.
172
+ * @param {{
173
+ * config: { apiKeyEnv?: string, orgId?: string, authFile?: string, dbFile?: string },
174
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
175
+ * }} input
176
+ */
177
+ export async function resolveOpencodeConsoleCredential(input) {
178
+ const { config, resolveCredential } = input;
179
+ const refs = uniqueStrings([config.apiKeyEnv, DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV]).filter(isCredentialRefName);
180
+ 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}`,
186
+ expires: null,
187
+ path: null
188
+ };
189
+ }
190
+ 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
+ };
204
+ }
205
+ return null;
206
+ }
207
+
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();
215
+ }
216
+
217
+ /**
218
+ * Non-network detection report for the settings page.
219
+ * @returns {Promise<{ enabled: boolean, configured: boolean, source: string | null, orgId: string | null, error: string | null }>}
220
+ */
221
+ export async function opencodeConsoleStatus(input) {
222
+ const { config, resolveCredential } = input;
223
+ const enabled = config.enabled !== false;
224
+ if (!enabled) return { enabled: false, configured: false, source: null, orgId: null, error: null };
225
+ try {
226
+ const credential = await resolveOpencodeConsoleCredential({ config, resolveCredential });
227
+ return {
228
+ 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
233
+ };
234
+ } catch (error) {
235
+ return {
236
+ enabled: true,
237
+ configured: false,
238
+ source: null,
239
+ orgId: null,
240
+ error: error instanceof Error ? error.message : String(error)
241
+ };
242
+ }
243
+ }
244
+
245
+ /** Pick the first finite number from `values` (numeric strings accepted). */
246
+ function finite(values) {
247
+ for (const value of values) {
248
+ const number = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
249
+ if (Number.isFinite(number)) return number;
250
+ }
251
+ return null;
252
+ }
253
+
254
+ /**
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
263
+ */
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
+ );
272
+ }
273
+ const baseUrl = String(config.baseUrl || DEFAULT_OPENCODE_CONSOLE_BASE_URL).replace(/\/+$/, "");
274
+ const headers = {
275
+ Accept: "application/json",
276
+ Authorization: `Bearer ${credential.token}`
277
+ };
278
+ if (typeof credential.orgId === "string" && credential.orgId.length > 0) {
279
+ headers["x-org-id"] = credential.orgId;
280
+ }
281
+ let res;
282
+ try {
283
+ res = await fetchImpl(`${baseUrl}/console/api/billing/status`, {
284
+ headers,
285
+ signal: AbortSignal.timeout(8_000)
286
+ });
287
+ } catch (error) {
288
+ throw new ConsoleError("network-error", `OpenCode balance: ${error instanceof Error ? error.message : String(error)}`);
289
+ }
290
+ if (!res.ok) {
291
+ const body = await res.text().catch(() => "");
292
+ throw new ConsoleError("http-error", `OpenCode balance ${res.status}: ${body.slice(0, 300)}`);
293
+ }
294
+ let payload;
295
+ try {
296
+ payload = await res.json();
297
+ } catch {
298
+ throw new ConsoleError("invalid-response", "OpenCode balance: response is not JSON");
299
+ }
300
+ const microCents = finite([payload?.availableMicroCents, payload?.balanceMicroCents]);
301
+ const cents = finite([payload?.availableCents, payload?.balanceCents]);
302
+ const direct = finite([payload?.balance, payload?.available]);
303
+ const amount = microCents !== null ? microCents / 100_000_000 : cents !== null ? cents / 100 : direct;
304
+ if (amount === null) {
305
+ throw new ConsoleError("invalid-response", "OpenCode balance: response has no numeric balance");
306
+ }
307
+ return {
308
+ amount,
309
+ currency: typeof payload?.currency === "string" && payload.currency.length > 0 ? payload.currency : "USD",
310
+ labelKey: "balance.zen",
311
+ source: credential.source,
312
+ details: {
313
+ billingMode: typeof payload?.billingMode === "string" ? payload.billingMode : null,
314
+ mode: typeof payload?.mode === "string" ? payload.mode : null,
315
+ canPurchaseCredits: payload?.canPurchaseCredits === true,
316
+ canEnableAutoRecharge: payload?.canEnableAutoRecharge === true,
317
+ microCents: microCents
318
+ }
319
+ };
320
+ }
321
+
@@ -0,0 +1,135 @@
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
+ const headers = new Headers(init?.headers);
82
+ if (!headers.has("x-opencode-session")) {
83
+ headers.set("x-opencode-session", String(meta.sessionId));
84
+ injectionCount += 1;
85
+ }
86
+ init = { ...init, headers };
87
+ diag.injected += 1;
88
+ } else {
89
+ diag.missed += 1;
90
+ }
91
+ diag.requests += 1;
92
+ }
93
+ return nativeFetch.call(this, input, init);
94
+ };
95
+ }
96
+
97
+ /**
98
+ * True when the request URL targets the given gateway base (trailing slashes
99
+ * ignored; the base itself or `base/...` both match).
100
+ */
101
+ function matchesBase(input, base) {
102
+ const url = typeof input === "string"
103
+ ? input
104
+ : input instanceof URL
105
+ ? input.href
106
+ : typeof input?.url === "string" ? input.url : String(input);
107
+ const normalized = String(base ?? "").replace(/\/+$/, "");
108
+ return normalized.length > 0 && (url === normalized || url.startsWith(normalized + "/"));
109
+ }
110
+
111
+ /**
112
+ * Run a stream's iteration inside the given session context, so async work
113
+ * created while the adapter pulls chunks resolves the context from the ALS
114
+ * store. Implements the async-iterator protocol.
115
+ * @template T
116
+ * @param {AsyncIterable<T>} stream
117
+ * @param {{ sessionId: string, base: string }} meta
118
+ * @returns {AsyncIterable<T>}
119
+ */
120
+ export function withSession(stream, meta) {
121
+ const iterator = stream[Symbol.asyncIterator]();
122
+ const run = (method, args) => {
123
+ const fn = iterator[method];
124
+ if (typeof fn !== "function") {
125
+ return Promise.resolve(method === "throw" ? { done: true } : void 0);
126
+ }
127
+ return sessionStore.run(meta, () => Promise.resolve(fn.call(iterator, ...args)));
128
+ };
129
+ return {
130
+ [Symbol.asyncIterator]() { return this; },
131
+ next: (...args) => run("next", args),
132
+ return: (...args) => run("return", args),
133
+ throw: (...args) => run("throw", args)
134
+ };
135
+ }