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.
- package/LICENSE +21 -0
- package/README.md +380 -2
- package/README.zh.md +341 -0
- package/cordis.patch.yml +15 -0
- package/docs/widget.png +0 -0
- package/lib/auto-update.js +559 -0
- package/lib/client.js +1753 -0
- package/lib/index.js +1278 -0
- package/lib/opencode-console.js +321 -0
- package/lib/opencode-session.js +135 -0
- package/lib/providers.js +902 -0
- package/lib/util.js +27 -0
- package/package.json +64 -4
|
@@ -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
|
+
}
|