dsh-qoder-connect 0.1.0

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,4082 @@
1
+ import crypto, { createHash, randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
8
+ import { EMPTY_RESPONSE_CODE, LlmError, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, contentHasImage, createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
9
+ import { homedir } from "node:os";
10
+ import { execFileSync } from "node:child_process";
11
+ //#region src/paths.ts
12
+ /**
13
+ * The plugin's data directory — the ONE place every file the plugin owns
14
+ * lives.
15
+ *
16
+ * Layout: `<profile>/.dsh-qoder-connect/state/` (the profile discovered
17
+ * the same way the credential store always did). Everything — PAT files,
18
+ * saved catalogs, probe records, the host heartbeat, and the machine-id
19
+ * seed — writes there, so a profile directory never collects loose
20
+ * `.qoder-*` files and the whole plugin's footprint is one folder.
21
+ *
22
+ * Fallbacks, in order: `DSH_QODER_DATA_DIR` env override → the discovered
23
+ * profile → the Harness home (a checkout running its own tests, or a host
24
+ * loading the plugin from outside any profile).
25
+ *
26
+ * Split out of `auth.ts` so catalog/probe/heartbeat stores can import the
27
+ * directory without pulling in the credential code (and its upstream
28
+ * dependency) — these modules stay leaf-light on purpose.
29
+ *
30
+ * @module dsh-qoder-connect/paths
31
+ */
32
+ /** The per-profile directory the plugin's data folder lives under. */
33
+ const QODER_DATA_DIR_NAME = ".dsh-qoder-connect";
34
+ /** The directory inside the data dir where the rebuildable state files live. */
35
+ const QODER_STATE_DIR_NAME = "state";
36
+ /** Environment override for the whole data directory. */
37
+ const QODER_DATA_DIR_ENV = "DSH_QODER_DATA_DIR";
38
+ const PROFILES_DIR_NAME = "profiles";
39
+ /** The npm name of this package, as a profile's manifest declares it. */
40
+ const PLUGIN_PACKAGE_NAME = "dsh-qoder-connect";
41
+ function pluginPackageRoot() {
42
+ try {
43
+ return dirname(dirname(fileURLToPath(import.meta.url)));
44
+ } catch {
45
+ return;
46
+ }
47
+ }
48
+ /**
49
+ * Whether a profile directory declares this plugin.
50
+ *
51
+ * Read from the profile's manifest rather than inferred from this module's own
52
+ * location, because DSH installs a plugin into a profile by *link*: the manifest
53
+ * carries `"dsh-qoder-connect": "link:/path/to/checkout"`, while Node
54
+ * resolves the module to that real path, which lies outside `$DSH_HOME` entirely.
55
+ * Walking up from the module would therefore miss the profile for exactly the
56
+ * install shape a developer uses.
57
+ */
58
+ function profileDeclaresPlugin(profileDir) {
59
+ try {
60
+ const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
61
+ return typeof manifest.dependencies?.[PLUGIN_PACKAGE_NAME] === "string" || typeof manifest.devDependencies?.[PLUGIN_PACKAGE_NAME] === "string";
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+ /**
67
+ * Whether a profile's installed copy of this plugin resolves to this package.
68
+ *
69
+ * This is what separates two profiles that both declare the plugin — a `web` and
70
+ * a `desktop` profile can each list it — so the data directory follows the
71
+ * profile whose copy is actually running rather than the first one found.
72
+ */
73
+ function profileLinksToThisPackage(profileDir) {
74
+ const own = pluginPackageRoot();
75
+ if (own === void 0) return false;
76
+ try {
77
+ return realpathSync(join(profileDir, "node_modules", PLUGIN_PACKAGE_NAME)) === realpathSync(own);
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * The profile directory this plugin belongs to, or undefined when none can be
84
+ * determined.
85
+ *
86
+ * A single declaring profile is accepted without the link test, so a normal
87
+ * (non-linked) install still resolves.
88
+ */
89
+ function discoverProfileDir() {
90
+ const profilesRoot = join(resolveDshHome(), PROFILES_DIR_NAME);
91
+ let entries;
92
+ try {
93
+ entries = readdirSync(profilesRoot, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ const candidates = [];
98
+ for (const entry of entries) {
99
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
100
+ const dir = join(profilesRoot, entry.name);
101
+ if (profileDeclaresPlugin(dir)) candidates.push(dir);
102
+ }
103
+ if (candidates.length === 0) return void 0;
104
+ if (candidates.length === 1) return candidates[0];
105
+ return candidates.find((candidate) => profileLinksToThisPackage(candidate));
106
+ }
107
+ /**
108
+ * The plugin's data directory: `<profile>/.dsh-qoder-connect`.
109
+ *
110
+ * Falls back to the Harness home when no profile can be discovered — a
111
+ * checkout running its own tests, or a host that loads the plugin from
112
+ * outside a profile — so the plugin always has somewhere to write, and
113
+ * `DSH_QODER_DATA_DIR` overrides either way.
114
+ */
115
+ function qoderPluginDataDir() {
116
+ const override = process.env[QODER_DATA_DIR_ENV];
117
+ if (override !== void 0 && override.trim() !== "") return override;
118
+ const base = discoverProfileDir() ?? resolveDshHome();
119
+ return join(base, QODER_DATA_DIR_NAME);
120
+ }
121
+ /**
122
+ * The directory the rebuildable state files live in:
123
+ * `<data dir>/state/` (saved catalogs, probe records, the host heartbeat).
124
+ */
125
+ function qoderStateDir() {
126
+ return join(qoderPluginDataDir(), QODER_STATE_DIR_NAME);
127
+ }
128
+ /**
129
+ * The plugin-owned seed for the Qoder transport's machine id.
130
+ *
131
+ * The transport's `getMachineId(paths)` helper reads a list of trusted
132
+ * locations and writes its created UUID into the last one; this is the
133
+ * DSH-side location the host wires into that chain, so the identifier stays
134
+ * inside the plugin's own folder instead of scattering files near `$HOME`.
135
+ */
136
+ function qoderMachineIdPath() {
137
+ return join(qoderStateDir(), ".qoder-machine-id");
138
+ }
139
+ //#endregion
140
+ //#region src/auth.ts
141
+ /**
142
+ * Qoder credential storage — one Personal Access Token per variant.
143
+ *
144
+ * Every credential the plugin holds is a PAT the human pasted into the
145
+ * variant's card (or exported into the environment, or saved by the CLI).
146
+ * There is no device flow, no token refresh, and no expiry: a PAT is valid
147
+ * until the user revokes it, so this store only reads, writes, and deletes a
148
+ * small JSON document.
149
+ *
150
+ * One file per variant inside the plugin's data directory
151
+ * (`<.dsh-qoder-connect>/`). The on-disk schema is
152
+ * `{version: 2, pat, region, savedAt}` — deliberately narrower than the
153
+ * WorkBuddy-era document it replaces: anything this file does not name is
154
+ * not a fact the plugin knows.
155
+ *
156
+ * @module dsh-qoder-connect/auth
157
+ */
158
+ /** The on-disk document version this writer produces. */
159
+ const AUTH_SCHEMA_VERSION = 2;
160
+ /** Environment fallback for the global arm when no file exists. */
161
+ const QODER_PAT_ENV_GLOBAL = "QODER_PERSONAL_ACCESS_TOKEN";
162
+ /** Environment fallback for the China arm when no file exists. */
163
+ const QODER_PAT_ENV_CN = "QODER_CN_PERSONAL_ACCESS_TOKEN";
164
+ /** Basename of the China variant's credential file inside the data directory. */
165
+ const QODER_AUTH_FILENAME = ".qoder-auth.json";
166
+ /** Last four characters of a token, for display. */
167
+ function patTail(pat) {
168
+ const trimmed = pat.trim();
169
+ return trimmed.length >= 4 ? trimmed.slice(-4) : void 0;
170
+ }
171
+ /**
172
+ * Stable identity key for a credential, used by probe records and saved
173
+ * catalogs.
174
+ *
175
+ * A PAT is a bearer secret, so the key is its one-way hash rather than any
176
+ * suffix of it: surfaces that show the key (cards, JSON diagnostics) leak
177
+ * nothing usable, while two reads of the same token still correlate locally.
178
+ */
179
+ function qoderCredentialIdentity(credential) {
180
+ return `pat:${createHash("sha256").update(credential.pat).digest("hex").slice(0, 16)}`;
181
+ }
182
+ function isRecord$1(value) {
183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
184
+ }
185
+ function isRegion(value) {
186
+ return value === "global" || value === "china";
187
+ }
188
+ var QoderCredentialStore = class {
189
+ variant;
190
+ ownPath;
191
+ logger;
192
+ /** The legacy-file warning is emitted once per process, not per read. */
193
+ legacyWarned = false;
194
+ constructor(options) {
195
+ this.variant = options.variant;
196
+ this.ownPath = options.ownPath ?? join(qoderPluginDataDir(), options.variant.ownFilename);
197
+ this.logger = options.logger;
198
+ }
199
+ /** Absolute path of this variant's credential file, for display and tests. */
200
+ ownAuthPath() {
201
+ return this.ownPath;
202
+ }
203
+ /**
204
+ * The credential currently in effect, or `undefined` when there is none.
205
+ *
206
+ * Resolution order: the plugin-owned file, then the arm's environment
207
+ * variable (only when no file exists — a saved token always wins over a
208
+ * stray env), then nothing. There is no refresh and no expiry check: a PAT
209
+ * lives until revoked.
210
+ */
211
+ async current() {
212
+ const stored = await this.readOwn();
213
+ if (stored !== void 0) return stored;
214
+ return this.fromEnvironment();
215
+ }
216
+ /**
217
+ * The credential, or a thrown `MISSING_CREDENTIAL` error.
218
+ *
219
+ * Callers that serve requests (the shim, the catalog sweep) use this so the
220
+ * absence of a token surfaces as the same classified failure the transport
221
+ * itself raises when a saved token turns out empty.
222
+ */
223
+ async resolve() {
224
+ const credential = await this.current();
225
+ if (credential === void 0 || credential.pat.trim() === "") throw new Error("qoder: MISSING_CREDENTIAL — no Personal Access Token is configured. Add one in the Qoder settings card.");
226
+ return credential;
227
+ }
228
+ /** The token in effect as a bare string, rejecting when there is none. */
229
+ async patPromise() {
230
+ return (await this.resolve()).pat;
231
+ }
232
+ /**
233
+ * Persist a token for this variant. The caller is responsible for having
234
+ * validated it against the region first (`validateApiKey` upstream).
235
+ */
236
+ async save(pat, source = "card") {
237
+ const trimmed = pat.trim();
238
+ if (trimmed === "") throw new Error("qoder: cannot save an empty Personal Access Token");
239
+ const credential = {
240
+ pat: trimmed,
241
+ region: this.variant.region,
242
+ savedAtMs: Date.now(),
243
+ source
244
+ };
245
+ await this.writeOwn(credential);
246
+ return credential;
247
+ }
248
+ /** Remove the stored credential. A missing file is not an error. */
249
+ async clear() {
250
+ try {
251
+ await rm(this.ownPath, { force: true });
252
+ } catch {}
253
+ }
254
+ /** Alias kept for call-site readability (`logout` is just clearing). */
255
+ async logout() {
256
+ await this.clear();
257
+ }
258
+ /** Redacted summary for the card, the PAT route, and doctor output. */
259
+ async status() {
260
+ const base = {
261
+ filePath: this.ownPath,
262
+ region: this.variant.region
263
+ };
264
+ const credential = await this.current();
265
+ if (credential === void 0) return {
266
+ state: "missing",
267
+ ...base,
268
+ ...this.pendingLegacyReason === void 0 ? {} : { reason: this.pendingLegacyReason }
269
+ };
270
+ const tail = patTail(credential.pat);
271
+ return {
272
+ state: "configured",
273
+ ...base,
274
+ pat: {
275
+ source: credential.source,
276
+ ...credential.savedAtMs === void 0 ? {} : { savedAtMs: credential.savedAtMs },
277
+ ...tail === void 0 ? {} : { patTail: tail }
278
+ }
279
+ };
280
+ }
281
+ /** Set when a read found a stale WorkBuddy-era file; consumed once. */
282
+ pendingLegacyReason;
283
+ async fromEnvironment() {
284
+ const name = this.variant.region === "china" ? QODER_PAT_ENV_CN : QODER_PAT_ENV_GLOBAL;
285
+ const value = process.env[name]?.trim();
286
+ if (value === void 0 || value === "") return void 0;
287
+ return {
288
+ pat: value,
289
+ region: this.variant.region,
290
+ source: "env"
291
+ };
292
+ }
293
+ async readOwn() {
294
+ let text;
295
+ try {
296
+ text = await readFile(this.ownPath, "utf8");
297
+ } catch (error) {
298
+ this.pendingLegacyReason = void 0;
299
+ return;
300
+ }
301
+ let parsed;
302
+ try {
303
+ parsed = JSON.parse(text);
304
+ } catch {
305
+ this.pendingLegacyReason = void 0;
306
+ return;
307
+ }
308
+ if (!isRecord$1(parsed)) {
309
+ this.pendingLegacyReason = void 0;
310
+ return;
311
+ }
312
+ if (typeof parsed["accessToken"] === "string" && parsed["pat"] === void 0) {
313
+ this.pendingLegacyReason = "stale WorkBuddy credential file; save a Qoder Personal Access Token instead";
314
+ if (!this.legacyWarned) {
315
+ this.legacyWarned = true;
316
+ this.logger?.warn?.(`qoder: ${this.variant.displayName}: ${this.ownPath} holds a WorkBuddy-era credential; it is ignored. Save a Qoder Personal Access Token to replace it.`);
317
+ }
318
+ return;
319
+ }
320
+ this.pendingLegacyReason = void 0;
321
+ const pat = typeof parsed["pat"] === "string" ? parsed["pat"].trim() : "";
322
+ if (pat === "") return void 0;
323
+ const region = isRegion(parsed["region"]) ? parsed["region"] : this.variant.region;
324
+ const savedAt = typeof parsed["savedAt"] === "number" && Number.isFinite(parsed["savedAt"]) ? parsed["savedAt"] : void 0;
325
+ return {
326
+ pat,
327
+ region,
328
+ ...savedAt === void 0 ? {} : { savedAtMs: savedAt },
329
+ source: "card"
330
+ };
331
+ }
332
+ async writeOwn(credential) {
333
+ const document = {
334
+ version: AUTH_SCHEMA_VERSION,
335
+ pat: credential.pat,
336
+ region: credential.region,
337
+ savedAt: credential.savedAtMs ?? Date.now()
338
+ };
339
+ const directory = dirname(this.ownPath);
340
+ try {
341
+ await mkdir(directory, {
342
+ recursive: true,
343
+ mode: 448
344
+ });
345
+ } catch {}
346
+ await writeFileAtomic(this.ownPath, `${JSON.stringify(document, null, 2)}\n`, {
347
+ mode: 384,
348
+ dirMode: 448
349
+ });
350
+ this.pendingLegacyReason = void 0;
351
+ }
352
+ };
353
+ /**
354
+ * The plugin-owned credential path for one variant's default filename.
355
+ * Kept as a free function for the CLI, which builds no store per display line.
356
+ */
357
+ function qoderOwnAuthPath(variant) {
358
+ return join(qoderPluginDataDir(), variant.ownFilename);
359
+ }
360
+ //#endregion
361
+ //#region src/status-paths.ts
362
+ /** Plugin-owned status endpoint consumed by its browser half. */
363
+ const QODER_STATUS_PATH = "/plugins/dsh-qoder-connect/status";
364
+ /**
365
+ * Plugin-owned probe control endpoint.
366
+ *
367
+ * Separate from the status route because it accepts writes: the status route's
368
+ * loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
369
+ * not the same as authorizing a state-changing action. This route therefore
370
+ * also requires the in-process key the browser half receives with the status
371
+ * document.
372
+ */
373
+ const QODER_PROBE_PATH = "/plugins/dsh-qoder-connect/probe";
374
+ /**
375
+ * Plugin-owned PAT endpoint, one per variant.
376
+ *
377
+ * A POST here saves (validates and persists) a Personal Access Token, or
378
+ * clears the stored one. Unlike the read-only status GET it is a write —
379
+ * it persists a credential — so it also requires the in-process key the
380
+ * browser half receives with the status document.
381
+ */
382
+ const QODER_AUTH_PATH = "/plugins/dsh-qoder-connect/auth";
383
+ /**
384
+ * The international (Qoder Global) variant's own triple of routes.
385
+ *
386
+ * Kept as separate constants rather than a computed suffix so both halves
387
+ * reference literal strings: the browser bundle and the host bundle are built
388
+ * independently, and a shared expression is one build-config drift away from
389
+ * the desk asking a route the host never mounted.
390
+ */
391
+ const QODER_GLOBAL_STATUS_PATH = "/plugins/dsh-qoder-connect/global/status";
392
+ const QODER_GLOBAL_PROBE_PATH = "/plugins/dsh-qoder-connect/global/probe";
393
+ const QODER_GLOBAL_AUTH_PATH = "/plugins/dsh-qoder-connect/global/auth";
394
+ //#endregion
395
+ //#region src/catalog.ts
396
+ /**
397
+ * The built-in roster, transcribed from the transport's own `defaultModels`
398
+ * (`src/qoder/catalog.ts`) — the Qoder model keys both regions start from
399
+ * before the first successful discovery: `cmodel`, `auto`, `ultimate`,
400
+ * `performance`, `efficient`, `lite`.
401
+ *
402
+ * It exists so the provider registers with a usable catalog while the first
403
+ * fetch is in flight or the endpoint is unreachable, and it is deliberately
404
+ * *not* a promise about the upstream's current state: the discovery answer
405
+ * replaces it at startup. No per-region roster is invented here — the
406
+ * transport ships one shared default set, and this mirror keeps the same
407
+ * discipline. Reasoning and rate metadata are absent because the defaults
408
+ * declare none; those rows offer no thinking control until the upstream says
409
+ * otherwise.
410
+ */
411
+ const FALLBACK_QODER_MODELS = [
412
+ {
413
+ id: "cmodel",
414
+ name: "Cantus (Qoder)",
415
+ contextWindow: 1e6,
416
+ maxTokens: 32768,
417
+ supportsImages: true,
418
+ billing: {
419
+ free: false,
420
+ rateUnknown: true
421
+ },
422
+ source: "system"
423
+ },
424
+ {
425
+ id: "auto",
426
+ name: "Qoder Auto",
427
+ contextWindow: 18e4,
428
+ maxTokens: 32768,
429
+ supportsImages: true,
430
+ billing: {
431
+ free: false,
432
+ rateUnknown: true
433
+ },
434
+ source: "system"
435
+ },
436
+ {
437
+ id: "ultimate",
438
+ name: "Qoder Ultimate",
439
+ contextWindow: 1e6,
440
+ maxTokens: 32768,
441
+ supportsImages: true,
442
+ billing: {
443
+ free: false,
444
+ rateUnknown: true
445
+ },
446
+ source: "system"
447
+ },
448
+ {
449
+ id: "performance",
450
+ name: "Qoder Performance",
451
+ contextWindow: 1e6,
452
+ maxTokens: 32768,
453
+ supportsImages: true,
454
+ billing: {
455
+ free: false,
456
+ rateUnknown: true
457
+ },
458
+ source: "system"
459
+ },
460
+ {
461
+ id: "efficient",
462
+ name: "Qoder Efficient",
463
+ contextWindow: 18e4,
464
+ maxTokens: 32768,
465
+ supportsImages: true,
466
+ billing: {
467
+ free: false,
468
+ rateUnknown: true
469
+ },
470
+ source: "system"
471
+ },
472
+ {
473
+ id: "lite",
474
+ name: "Qoder Lite",
475
+ contextWindow: 18e4,
476
+ maxTokens: 32768,
477
+ supportsImages: false,
478
+ billing: {
479
+ free: false,
480
+ rateUnknown: true
481
+ },
482
+ source: "system"
483
+ }
484
+ ];
485
+ /**
486
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
487
+ *
488
+ * Visibility is separate from content. A variant with no usable credential
489
+ * must expose *no* models rather than a fallback roster: the DSH model picker
490
+ * drops an empty group, so an empty catalog is exactly how a provider hides
491
+ * without touching registration. Serving the fallback to a signed-out user
492
+ * instead offers models that can only fail (the transport throws
493
+ * `MISSING_CREDENTIAL` on the first message), which is worse than showing
494
+ * nothing.
495
+ *
496
+ * The flag defaults to visible so a directly-constructed catalog behaves as
497
+ * it always has; the plugin runtime applies the credential gate.
498
+ */
499
+ var QoderCatalog = class {
500
+ models;
501
+ visible = true;
502
+ useMaximumContextWindow = false;
503
+ /** Per-model window overrides (model id → tokens); an override wins over the preference. */
504
+ modelContextWindows = {};
505
+ constructor(initial = FALLBACK_QODER_MODELS) {
506
+ this.models = initial;
507
+ }
508
+ /** Current entries; empty while the variant has no usable credential. */
509
+ current() {
510
+ if (!this.visible) return [];
511
+ return this.models.map((model) => {
512
+ const override = this.modelContextWindows[model.id];
513
+ if (override !== void 0 && override > 0) return {
514
+ ...model,
515
+ defaultContextWindow: model.defaultContextWindow ?? model.contextWindow,
516
+ contextWindow: override
517
+ };
518
+ const maximum = model.supportedContextWindows === void 0 ? void 0 : Math.max(...model.supportedContextWindows);
519
+ return this.useMaximumContextWindow && maximum !== void 0 && maximum > model.contextWindow ? {
520
+ ...model,
521
+ defaultContextWindow: model.defaultContextWindow ?? model.contextWindow,
522
+ contextWindow: maximum
523
+ } : model;
524
+ });
525
+ }
526
+ /** Replace the list; callers invalidate their adapter snapshot after this. */
527
+ set(models) {
528
+ this.models = [...models];
529
+ }
530
+ /** Whether this variant's models are exposed at all. */
531
+ isVisible() {
532
+ return this.visible;
533
+ }
534
+ /**
535
+ * Show or hide the whole catalog. Returns whether the value changed, so the
536
+ * caller can skip an invalidation that would re-render an identical list.
537
+ */
538
+ setVisible(visible) {
539
+ if (this.visible === visible) return false;
540
+ this.visible = visible;
541
+ return true;
542
+ }
543
+ /** Select the largest declared window where the upstream offers one. */
544
+ setUseMaximumContextWindow(useMaximum) {
545
+ if (this.useMaximumContextWindow === useMaximum) return false;
546
+ this.useMaximumContextWindow = useMaximum;
547
+ return true;
548
+ }
549
+ /**
550
+ * Replace the per-model window overrides wholesale. Returns whether the map
551
+ * changed, so the caller can skip an invalidation over an identical write.
552
+ */
553
+ setModelContextWindows(modelContextWindows) {
554
+ const next = { ...modelContextWindows };
555
+ if (JSON.stringify(next) === JSON.stringify(this.modelContextWindows)) return false;
556
+ this.modelContextWindows = next;
557
+ return true;
558
+ }
559
+ /**
560
+ * Models to fall back to when the upstream fetch fails; ignores
561
+ * visibility, because the caller asking for the fallback already knows the
562
+ * credential state.
563
+ */
564
+ fallback() {
565
+ return this.models;
566
+ }
567
+ };
568
+ //#endregion
569
+ //#region src/catalog-store.ts
570
+ /**
571
+ * The last catalog that actually loaded, kept per variant and per account.
572
+ *
573
+ * Both the plan (§4 "降级顺序为同账号的最近成功目录 → 本版内置保守目录")
574
+ * and the README promise this fallback, and without it a restart always drops
575
+ * the user to the built-in roster even when a good catalog was fetched minutes
576
+ * earlier. The built-in roster is a snapshot taken once; a fetched catalog is
577
+ * what the upstream actually serves to this account.
578
+ *
579
+ * What it deliberately is *not*:
580
+ *
581
+ * - not a cache with a freshness policy — it never prevents a fetch, it only
582
+ * answers when a fetch cannot;
583
+ * - not shared across accounts (a different account can see a different roster),
584
+ * nor across variants (the two regions serve overlapping ids with different
585
+ * capacity and rate answers);
586
+ * - not a place for secrets: model metadata only, never a token. The account
587
+ * key is a one-way hash of the credential, not the credential itself.
588
+ *
589
+ * @module dsh-qoder-connect/catalog-store
590
+ */
591
+ /** On-disk format this reader accepts; other versions are discarded. */
592
+ const CATALOG_FORMAT_VERSION = 1;
593
+ /** Plugin-owned saved-catalog path inside the plugin's state directory. */
594
+ function qoderCatalogPath(filename) {
595
+ return join(qoderStateDir(), filename);
596
+ }
597
+ /** Whether a parsed value is a model row worth keeping. */
598
+ function isModel(value) {
599
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
600
+ const row = value;
601
+ return typeof row["id"] === "string" && row["id"] !== "" && typeof row["name"] === "string" && typeof row["contextWindow"] === "number" && Number.isFinite(row["contextWindow"]) && typeof row["maxTokens"] === "number" && Number.isFinite(row["maxTokens"]) && typeof row["supportsImages"] === "boolean";
602
+ }
603
+ /** Whether a parsed value is a saved catalog this reader can trust. */
604
+ function isSaved(value) {
605
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
606
+ const entry = value;
607
+ if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
608
+ if (typeof entry["source"] !== "string" || entry["source"] === "") return false;
609
+ if (typeof entry["fetchedAtMs"] !== "number" || !Number.isFinite(entry["fetchedAtMs"])) return false;
610
+ const models = entry["models"];
611
+ if (!Array.isArray(models) || models.length === 0) return false;
612
+ return models.every(isModel);
613
+ }
614
+ /**
615
+ * The last successful catalog per account, read once and written atomically.
616
+ *
617
+ * Malformed content reads as "nothing saved" rather than throwing: this file
618
+ * is an optimization for the offline and first-seconds cases, and a corrupt one
619
+ * must never be able to stop the plugin from serving models.
620
+ */
621
+ var QoderCatalogStore = class {
622
+ path;
623
+ entries;
624
+ constructor(options = {}) {
625
+ this.path = typeof options === "string" ? options : options.path ?? qoderCatalogPath(".qoder-catalog.json");
626
+ }
627
+ /** Resolved state-file path, for the CLI and tests. */
628
+ filePath() {
629
+ return this.path;
630
+ }
631
+ load() {
632
+ if (this.entries !== void 0) return this.entries;
633
+ const entries = {};
634
+ if (existsSync(this.path)) try {
635
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
636
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
637
+ const document = parsed;
638
+ const raw = document["version"] === CATALOG_FORMAT_VERSION ? document["entries"] : void 0;
639
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
640
+ for (const [key, value] of Object.entries(raw)) if (isSaved(value)) entries[key] = value;
641
+ }
642
+ }
643
+ } catch {}
644
+ this.entries = entries;
645
+ return entries;
646
+ }
647
+ /** The saved catalog for one account, or `undefined` when there is none. */
648
+ get(account) {
649
+ const entry = this.load()[account];
650
+ return entry === void 0 ? void 0 : entry;
651
+ }
652
+ /**
653
+ * Remember a catalog for an account, replacing whatever was saved before.
654
+ *
655
+ * A failed write is swallowed: the plugin has already served these models,
656
+ * and losing the *memory* of them is not worth surfacing.
657
+ */
658
+ set(account, catalog) {
659
+ const entries = this.load();
660
+ entries[account] = {
661
+ account,
662
+ ...catalog
663
+ };
664
+ this.persist();
665
+ }
666
+ /** Forget one account's catalog — used when that account signs out. */
667
+ delete(account) {
668
+ const entries = this.load();
669
+ if (!(account in entries)) return;
670
+ delete entries[account];
671
+ this.persist();
672
+ }
673
+ persist() {
674
+ const directory = dirname(this.path);
675
+ try {
676
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
677
+ const document = {
678
+ version: CATALOG_FORMAT_VERSION,
679
+ entries: this.load()
680
+ };
681
+ const temporary = resolve(`${this.path}.tmp`);
682
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
683
+ renameSync(temporary, this.path);
684
+ } catch {}
685
+ }
686
+ };
687
+ //#endregion
688
+ //#region src/probe.ts
689
+ /**
690
+ * The reasoning-effort probe: decide whether a model's `reasoning_effort`
691
+ * parameter is actually validated, and if so which canonical values it accepts.
692
+ *
693
+ * Implements `docs/reasoning-effort-probe-plan.md` §4. The order matters and is
694
+ * not an optimization:
695
+ *
696
+ * 1. **Baseline** (no `reasoning_effort`) proves the model, credential, and
697
+ * request shape work at all, so a later rejection can be attributed.
698
+ * 2. **Sentinel** (a fresh random, impossible-to-collide value) answers the one
699
+ * question a per-level sweep cannot: does the upstream validate the field?
700
+ * A model that accepts the sentinel answers 200 to *everything*, so its
701
+ * per-level results would be uniformly false positives.
702
+ * 3. **Levels**, only after the sentinel was refused.
703
+ *
704
+ * The result is an observation, never a capability claim. Even a fully
705
+ * successful sweep means "the upstream accepted these spellings", not "these
706
+ * spellings change how the model thinks".
707
+ *
708
+ * @module dsh-qoder-connect/probe
709
+ */
710
+ /**
711
+ * The canonical values a probe tests, in a fixed order.
712
+ *
713
+ * `minimal` is absent: it appears in no upstream vocabulary. `off` is absent
714
+ * by policy — disabling thinking is a separate capability the upstream must
715
+ * declare through `canDisableThinking`, never something probing may infer.
716
+ */
717
+ const PROBE_EFFORT_CANDIDATES = [
718
+ "low",
719
+ "medium",
720
+ "high",
721
+ "xhigh",
722
+ "max"
723
+ ];
724
+ /** Prompt body used by every probe request; carries nothing user-specific. */
725
+ const PROBE_PROMPT = "ping";
726
+ /** Default sentinel: unmistakably non-canonical, different on every call. */
727
+ function randomSentinel() {
728
+ return `probe_sentinel_${randomBytes(12).toString("hex")}`;
729
+ }
730
+ /**
731
+ * The transport's rejection code for an effort the model does not advertise
732
+ * (`src/qoder/transport/wire/serialize.ts`).
733
+ *
734
+ * With the Qoder upstream this rejection is raised *locally* against the
735
+ * discovery catalog before any network call: a probe therefore measures the
736
+ * catalog's declaration as much as the endpoint behind it. That is a weaker
737
+ * finding than the WorkBuddy-era one, and it is what the plan's Stage D will
738
+ * weigh; the sequence below still distinguishes "advertises and validates"
739
+ * (per-level refusals after an accepted baseline) from "accepts anything the
740
+ * catalog does not name" — the sentinel answers that question against the
741
+ * same vocabulary the request path will later enforce.
742
+ */
743
+ const INVALID_EFFORT_CODE = "UNSUPPORTED_REASONING_EFFORT";
744
+ /** Whether an attempt is an attributable rejection of the effort value. */
745
+ function isEffortRejection(attempt) {
746
+ return attempt.status === 400 && attempt.errorCode === INVALID_EFFORT_CODE;
747
+ }
748
+ /** Whether an attempt shows the upstream accepted the request and streamed. */
749
+ function isAcceptance(attempt) {
750
+ return attempt.status === 200 && attempt.streamed;
751
+ }
752
+ /** Why an attempt ended in `unknown`, phrased for a log line. */
753
+ function unknownReason(stage, attempt) {
754
+ const code = attempt.errorCode === void 0 ? "" : ` (${attempt.errorCode})`;
755
+ const detail = attempt.detail === void 0 ? "" : `: ${attempt.detail}`;
756
+ return `${stage} status ${attempt.status}${code}${detail}`;
757
+ }
758
+ /**
759
+ * Probe one model.
760
+ *
761
+ * `options.candidates` exists so tests can shorten the sweep; production always
762
+ * uses {@link PROBE_EFFORT_CANDIDATES}.
763
+ */
764
+ async function probeModel(options) {
765
+ const sentinel = options.sentinel ?? randomSentinel;
766
+ const candidates = options.candidates ?? PROBE_EFFORT_CANDIDATES;
767
+ const timeoutMs = options.timeoutMs ?? 3e4;
768
+ let requests = 0;
769
+ const attempt = async (effort) => {
770
+ requests += 1;
771
+ const controller = new AbortController();
772
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
773
+ try {
774
+ return await options.send(effort, controller.signal);
775
+ } catch (error) {
776
+ return {
777
+ status: 0,
778
+ streamed: false,
779
+ detail: `transport error: ${String(error)}`
780
+ };
781
+ } finally {
782
+ clearTimeout(timer);
783
+ }
784
+ };
785
+ const baseline = await attempt(void 0);
786
+ if (!isAcceptance(baseline)) return {
787
+ validation: "unknown",
788
+ efforts: [],
789
+ requests,
790
+ reason: unknownReason("baseline", baseline)
791
+ };
792
+ const sentinelAttempt = await attempt(sentinel());
793
+ if (isAcceptance(sentinelAttempt)) return {
794
+ validation: "non-validating",
795
+ efforts: [],
796
+ requests
797
+ };
798
+ if (!isEffortRejection(sentinelAttempt)) return {
799
+ validation: "unknown",
800
+ efforts: [],
801
+ requests,
802
+ reason: unknownReason("sentinel", sentinelAttempt)
803
+ };
804
+ const accepted = [];
805
+ for (const effort of candidates) {
806
+ const levelAttempt = await attempt(effort);
807
+ if (isAcceptance(levelAttempt)) {
808
+ accepted.push(effort);
809
+ continue;
810
+ }
811
+ if (isEffortRejection(levelAttempt)) continue;
812
+ return {
813
+ validation: "unknown",
814
+ efforts: [],
815
+ requests,
816
+ reason: unknownReason(`level ${effort}`, levelAttempt)
817
+ };
818
+ }
819
+ return {
820
+ validation: "validating",
821
+ efforts: accepted,
822
+ requests
823
+ };
824
+ }
825
+ //#endregion
826
+ //#region src/qoder/transport/endpoints.ts
827
+ const qoderRegionEndpoints = {
828
+ global: {
829
+ baseUrl: "https://api3.qoder.sh/",
830
+ openApiUrl: "https://openapi.qoder.sh",
831
+ centerUrl: "https://center.qoder.sh"
832
+ },
833
+ china: {
834
+ baseUrl: "https://gateway.qoder.com.cn/",
835
+ openApiUrl: "https://openapi.qoder.com.cn",
836
+ centerUrl: "https://gateway.qoder.com.cn"
837
+ }
838
+ };
839
+ /** Signed path of the center image upload route; it never carries an `/algo` prefix. */
840
+ const qoderImageUploadPath = "/api/v2/image/upload";
841
+ qoderRegionEndpoints.global.baseUrl;
842
+ qoderRegionEndpoints.global.openApiUrl;
843
+ function resolveQoderEndpoints(region = "global") {
844
+ return qoderRegionEndpoints[region] ?? qoderRegionEndpoints.global;
845
+ }
846
+ function getQoderChatUrl(region = "global") {
847
+ const { baseUrl } = resolveQoderEndpoints(region);
848
+ return `${baseUrl}algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
849
+ }
850
+ function getQoderModelListUrl(region = "global") {
851
+ const { baseUrl } = resolveQoderEndpoints(region);
852
+ return `${baseUrl}algo/api/v2/model/list?Encode=1`;
853
+ }
854
+ function getQoderImageUploadUrl(region = "global", requestId) {
855
+ const { centerUrl } = resolveQoderEndpoints(region);
856
+ const base = `${centerUrl.replace(/\/+$/u, "")}/algo${qoderImageUploadPath}`;
857
+ return requestId === void 0 ? base : `${base}?request_id=${encodeURIComponent(requestId)}`;
858
+ }
859
+ function getQoderExchangeUrl(region = "global") {
860
+ const { openApiUrl } = resolveQoderEndpoints(region);
861
+ return `${openApiUrl}/api/v1/jobToken/exchange`;
862
+ }
863
+ function getQoderUserInfoUrl(region = "global") {
864
+ const { openApiUrl } = resolveQoderEndpoints(region);
865
+ return `${openApiUrl}/api/v1/userinfo`;
866
+ }
867
+ function getQoderUsageUrl(region = "global") {
868
+ const { openApiUrl } = resolveQoderEndpoints(region);
869
+ return `${openApiUrl}/api/v2/quota/usage`;
870
+ }
871
+ function getQoderUserPlanUrl(region = "global") {
872
+ const { openApiUrl } = resolveQoderEndpoints(region);
873
+ return `${openApiUrl}/api/v2/user/plan`;
874
+ }
875
+ function getQoderUserStatusUrl(region = "global") {
876
+ const { openApiUrl } = resolveQoderEndpoints(region);
877
+ return `${openApiUrl}/api/v3/user/status`;
878
+ }
879
+ //#endregion
880
+ //#region src/qoder/errors.ts
881
+ /**
882
+ * DSH-compatible LLM error representation.
883
+ *
884
+ * @module dsh-provider-qoder/qoder/errors
885
+ */
886
+ var QoderLlmError = class extends LlmError {
887
+ constructor(message, code = "UNKNOWN_ERROR", options) {
888
+ super(message, code, options);
889
+ }
890
+ };
891
+ function qoderHttpError(message, response) {
892
+ const { status } = response;
893
+ const code = status === 401 || status === 403 ? "AUTH" : status === 408 ? "TIMEOUT" : status === 429 ? "RATE_LIMIT" : status >= 500 && status <= 599 ? "SERVER" : status >= 400 && status <= 499 ? "INVALID_REQUEST" : "PROVIDER_ERROR";
894
+ const providerRetryAfterMs = retryAfterMs(response.headers?.get("retry-after") ?? null);
895
+ const requestId = qoderRequestId(response.headers);
896
+ return new QoderLlmError(message, code, {
897
+ status,
898
+ ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
899
+ ...requestId === void 0 ? {} : { requestId }
900
+ });
901
+ }
902
+ function qoderRequestId(headers) {
903
+ const normalized = (headers?.get("x-request-id") ?? headers?.get("request-id") ?? headers?.get("x-amzn-requestid"))?.trim();
904
+ return normalized ? ProviderRequestId(normalized) : void 0;
905
+ }
906
+ function retryAfterMs(value, nowMs = Date.now()) {
907
+ const normalized = value?.trim();
908
+ if (!normalized) return void 0;
909
+ if (/^\d+$/u.test(normalized)) {
910
+ const delayMs = Number(normalized) * 1e3;
911
+ return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : void 0;
912
+ }
913
+ const retryAt = Date.parse(normalized);
914
+ if (Number.isNaN(retryAt)) return void 0;
915
+ const delayMs = retryAt - nowMs;
916
+ return delayMs > 0 ? delayMs : void 0;
917
+ }
918
+ //#endregion
919
+ //#region src/qoder/transport/machine-id.ts
920
+ function defaultMachineIdPaths() {
921
+ return [join(homedir(), ".qoder", ".auth", "machine_id"), join(homedir(), ".dsh", "qoder", "machine_id")];
922
+ }
923
+ /** Read Qoder's machine id or create the DSH-owned fallback. */
924
+ function getMachineId(paths = defaultMachineIdPaths()) {
925
+ for (const path of paths) {
926
+ if (!existsSync(path)) continue;
927
+ try {
928
+ const value = readFileSync(path, "utf8").trim();
929
+ if (value) return value;
930
+ } catch {}
931
+ }
932
+ const machineId = crypto.randomUUID();
933
+ const savePath = paths.at(-1);
934
+ if (savePath !== void 0) try {
935
+ mkdirSync(dirname(savePath), { recursive: true });
936
+ writeFileSync(savePath, machineId, {
937
+ encoding: "utf8",
938
+ flag: "wx"
939
+ });
940
+ } catch {
941
+ try {
942
+ const existing = readFileSync(savePath, "utf8").trim();
943
+ if (existing) return existing;
944
+ } catch {}
945
+ }
946
+ return machineId;
947
+ }
948
+ //#endregion
949
+ //#region src/qoder/transport/logging.ts
950
+ const sensitiveKey = /(?:authorization|credential|password|secret|token)/iu;
951
+ const identifierKey = /^(?:accountId|id|uid|userId)$/iu;
952
+ const tokenValue = /\b(?:jrt|jt|pt)-[\w.-]+\b/giu;
953
+ const bearerValue = /Bearer\s+[^\s,;]+/giu;
954
+ const maxDepth = 4;
955
+ const maxEntries = 50;
956
+ const maxStringLength = 500;
957
+ function maskIdentifier(value) {
958
+ const text = String(value);
959
+ return text.length <= 4 ? "[REDACTED]" : `…${text.slice(-4)}`;
960
+ }
961
+ function maskEmail(value) {
962
+ const text = String(value);
963
+ const at = text.indexOf("@");
964
+ if (at <= 0) return "[REDACTED]";
965
+ return `${text.slice(0, 1)}***${text.slice(at)}`;
966
+ }
967
+ function redactString(value) {
968
+ return (value.length > maxStringLength ? `${value.slice(0, maxStringLength)}…` : value).replace(tokenValue, "[REDACTED]").replace(bearerValue, "Bearer [REDACTED]");
969
+ }
970
+ /** Redact credentials and bound arbitrary provider values before logging them. */
971
+ function redactLogValue(value, depth = 0) {
972
+ if (typeof value === "string") return redactString(value);
973
+ if (value === null || typeof value !== "object") return value;
974
+ if (depth >= maxDepth) return "[TRUNCATED]";
975
+ if (value instanceof Error) return {
976
+ name: value.name,
977
+ message: redactString(value.message),
978
+ ..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {}
979
+ };
980
+ if (Array.isArray(value)) return value.slice(0, maxEntries).map((item) => redactLogValue(item, depth + 1));
981
+ return Object.fromEntries(Object.entries(value).slice(0, maxEntries).map(([key, item]) => {
982
+ if (sensitiveKey.test(key)) return [key, "[REDACTED]"];
983
+ if (identifierKey.test(key)) return [key, maskIdentifier(item)];
984
+ if (key.toLowerCase() === "email") return [key, maskEmail(item)];
985
+ return [key, redactLogValue(item, depth + 1)];
986
+ }));
987
+ }
988
+ /** Parse JSON-shaped diagnostics when possible, then apply the same redaction boundary. */
989
+ function redactLogPayload(text) {
990
+ try {
991
+ return redactLogValue(JSON.parse(text));
992
+ } catch {
993
+ return redactLogValue(text);
994
+ }
995
+ }
996
+ /** Log one successfully parsed non-stream response through the shared redaction policy. */
997
+ function logParsedResponse(logger, operation, result) {
998
+ logger?.debug?.("[Qoder Response] Parsed", redactLogValue({
999
+ operation,
1000
+ result
1001
+ }));
1002
+ }
1003
+ //#endregion
1004
+ //#region src/qoder/transport/wire/cosy.ts
1005
+ /**
1006
+ * COSY authentication headers and signature algorithm for Qoder services.
1007
+ *
1008
+ * Implements RSA + AES + MD5 signature generation required by the upstream
1009
+ * Qoder gateway.
1010
+ *
1011
+ * @module dsh-provider-qoder/qoder/transport/wire/cosy
1012
+ */
1013
+ const qoderRSAPublicKey = `-----BEGIN PUBLIC KEY-----
1014
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
1015
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
1016
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
1017
+ XcW+ML9FoCI6AOvOzwIDAQAB
1018
+ -----END PUBLIC KEY-----`;
1019
+ const qoderIdeVersion = "1.1.47";
1020
+ const defaultUserAgent = `qoder/${qoderIdeVersion}`;
1021
+ const qoderDataPolicy = "disagree";
1022
+ const qoderLoginVersion = "v2";
1023
+ const qoderMachineOs = process.platform === "win32" ? process.arch === "arm64" ? "aarch64_windows" : "x86_64_windows" : process.arch === "arm64" ? "aarch64_linux" : "x86_64_linux";
1024
+ const qoderMachineTypeMagic = "5";
1025
+ function computeSigPath(urlStr) {
1026
+ let sigPath = new URL(urlStr).pathname;
1027
+ if (sigPath.startsWith("/algo")) sigPath = sigPath.substring(5);
1028
+ return sigPath;
1029
+ }
1030
+ function rsaEncryptBase64(data) {
1031
+ const key = {
1032
+ key: qoderRSAPublicKey,
1033
+ padding: crypto.constants.RSA_PKCS1_PADDING
1034
+ };
1035
+ return crypto.publicEncrypt(key, typeof data === "string" ? Buffer.from(data) : data).toString("base64");
1036
+ }
1037
+ function aesEncryptCBCBase64(plaintext, keyStr) {
1038
+ const cipher = crypto.createCipheriv("aes-128-cbc", Buffer.from(keyStr), Buffer.from(keyStr));
1039
+ let encrypted = cipher.update(plaintext, "utf8", "base64");
1040
+ encrypted += cipher.final("base64");
1041
+ return encrypted;
1042
+ }
1043
+ function buildAuthHeaders(body, requestURL, creds) {
1044
+ if (!creds.userID) throw new Error("cosy: user id is empty");
1045
+ if (!creds.authToken) throw new Error("cosy: auth token is empty");
1046
+ const aesKey = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
1047
+ const userInfo = {
1048
+ uid: creds.userID,
1049
+ security_oauth_token: creds.authToken,
1050
+ name: creds.name || "",
1051
+ aid: "",
1052
+ email: creds.email || ""
1053
+ };
1054
+ const infoB64 = aesEncryptCBCBase64(JSON.stringify(userInfo), aesKey);
1055
+ const cosyKey = rsaEncryptBase64(aesKey);
1056
+ const timestamp = Math.floor(Date.now() / 1e3).toString();
1057
+ const cosyPayload = {
1058
+ version: "v1",
1059
+ requestId: crypto.randomUUID(),
1060
+ info: infoB64,
1061
+ cosyVersion: qoderIdeVersion,
1062
+ ideVersion: ""
1063
+ };
1064
+ const payloadB64 = Buffer.from(JSON.stringify(cosyPayload)).toString("base64");
1065
+ const sigPath = computeSigPath(requestURL);
1066
+ const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${body ? Buffer.isBuffer(body) ? body.toString("utf8") : body : ""}\n${sigPath}`;
1067
+ const sig = crypto.createHash("md5").update(sigInput).digest("hex");
1068
+ const bodyHash = crypto.createHash("md5").update(body || "").digest("hex");
1069
+ const bodyLen = body ? (Buffer.isBuffer(body) ? body.length : Buffer.from(body).length).toString() : "0";
1070
+ const machineID = creds.machineID || getMachineId();
1071
+ return {
1072
+ Authorization: `Bearer COSY.${payloadB64}.${sig}`,
1073
+ "Cosy-Key": cosyKey,
1074
+ "Cosy-User": creds.userID,
1075
+ "Cosy-Date": timestamp,
1076
+ "Cosy-Version": qoderIdeVersion,
1077
+ "Cosy-Machineid": machineID,
1078
+ "Cosy-Machinetoken": machineID,
1079
+ "Cosy-Machinetype": qoderMachineTypeMagic,
1080
+ "Cosy-Machineos": qoderMachineOs,
1081
+ "Cosy-Clienttype": "5",
1082
+ "Cosy-Clientip": "127.0.0.1",
1083
+ "Cosy-Bodyhash": bodyHash,
1084
+ "Cosy-Bodylength": bodyLen,
1085
+ "Cosy-Sigpath": sigPath,
1086
+ "Cosy-Data-Policy": qoderDataPolicy,
1087
+ "Cosy-Organization-Id": "",
1088
+ "Cosy-Organization-Tags": "",
1089
+ "Login-Version": qoderLoginVersion,
1090
+ "X-Request-Id": crypto.randomUUID()
1091
+ };
1092
+ }
1093
+ const defaultMaxJsonBytes = 2097152;
1094
+ const defaultMaxErrorBytes = 16384;
1095
+ const metadataRetryBaseDelayMs = 200;
1096
+ const maxProviderRetryDelayMs = 1e4;
1097
+ function opaqueCredentialKey(value) {
1098
+ return createHash("sha256").update(value).digest("hex");
1099
+ }
1100
+ function retryableMetadataError(error) {
1101
+ return error instanceof QoderLlmError && [
1102
+ "RATE_LIMIT",
1103
+ "SERVER",
1104
+ "TIMEOUT",
1105
+ "TRANSPORT"
1106
+ ].includes(error.code);
1107
+ }
1108
+ function abortableDelay(ms, signal) {
1109
+ if (signal.aborted) return Promise.reject(new DOMException("aborted", "AbortError"));
1110
+ return new Promise((resolve, reject) => {
1111
+ const timer = setTimeout(() => {
1112
+ signal.removeEventListener("abort", onAbort);
1113
+ resolve();
1114
+ }, ms);
1115
+ const onAbort = () => {
1116
+ clearTimeout(timer);
1117
+ reject(new DOMException("aborted", "AbortError"));
1118
+ };
1119
+ signal.addEventListener("abort", onAbort, { once: true });
1120
+ });
1121
+ }
1122
+ async function retryMetadataRead(signal, operation) {
1123
+ try {
1124
+ return await operation();
1125
+ } catch (error) {
1126
+ if (!retryableMetadataError(error) || signal.aborted) throw error;
1127
+ const providerDelay = error.failure.providerRetryAfterMs;
1128
+ await abortableDelay(providerDelay === void 0 ? metadataRetryBaseDelayMs + Math.floor(Math.random() * 41) : Math.min(providerDelay, maxProviderRetryDelayMs), signal);
1129
+ return operation();
1130
+ }
1131
+ }
1132
+ function withDeadline(signal, timeoutMs) {
1133
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
1134
+ return {
1135
+ timeoutSignal,
1136
+ signal: signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal])
1137
+ };
1138
+ }
1139
+ async function readLimitedText(response, maxBytes, label) {
1140
+ const declaredLength = Number(response.headers.get("content-length"));
1141
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
1142
+ await response.body?.cancel().catch(() => void 0);
1143
+ throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
1144
+ }
1145
+ if (!response.body) return "";
1146
+ const reader = response.body.getReader();
1147
+ const decoder = new TextDecoder();
1148
+ let bytes = 0;
1149
+ let text = "";
1150
+ try {
1151
+ while (true) {
1152
+ const { done, value } = await reader.read();
1153
+ if (done) break;
1154
+ bytes += value.byteLength;
1155
+ if (bytes > maxBytes) throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
1156
+ text += decoder.decode(value, { stream: true });
1157
+ }
1158
+ return text + decoder.decode();
1159
+ } finally {
1160
+ await reader.cancel().catch(() => void 0);
1161
+ reader.releaseLock();
1162
+ }
1163
+ }
1164
+ var SingleFlight = class {
1165
+ flights = /* @__PURE__ */ new Map();
1166
+ run(key, signal, start, abortedError) {
1167
+ if (signal?.aborted) return Promise.reject(abortedError());
1168
+ let flight = this.flights.get(key);
1169
+ if (flight === void 0 || flight.controller.signal.aborted) {
1170
+ const controller = new AbortController();
1171
+ const created = {};
1172
+ created.controller = controller;
1173
+ created.settled = false;
1174
+ created.waiters = 0;
1175
+ created.promise = start(controller.signal).finally(() => {
1176
+ created.settled = true;
1177
+ if (this.flights.get(key) === created) this.flights.delete(key);
1178
+ });
1179
+ flight = created;
1180
+ this.flights.set(key, created);
1181
+ }
1182
+ flight.waiters++;
1183
+ return new Promise((resolve, reject) => {
1184
+ let finished = false;
1185
+ const finish = (callback) => {
1186
+ if (finished) return;
1187
+ finished = true;
1188
+ signal?.removeEventListener("abort", onAbort);
1189
+ flight.waiters--;
1190
+ if (flight.waiters === 0 && !flight.settled) {
1191
+ if (this.flights.get(key) === flight) this.flights.delete(key);
1192
+ flight.controller.abort("all callers aborted");
1193
+ }
1194
+ callback();
1195
+ };
1196
+ const onAbort = () => finish(() => reject(abortedError()));
1197
+ signal?.addEventListener("abort", onAbort, { once: true });
1198
+ flight.promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
1199
+ });
1200
+ }
1201
+ };
1202
+ async function openApiJsonRequest(fetchImpl, options) {
1203
+ const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
1204
+ const timeoutMs = options.timeoutMs ?? 15e3;
1205
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
1206
+ const requestSignal = options.signal === void 0 ? timeoutSignal : AbortSignal.any([options.signal, timeoutSignal]);
1207
+ const startedAt = performance.now();
1208
+ options.logger?.debug?.(`[Qoder ${options.operation}] Requesting`, {
1209
+ url: options.url,
1210
+ method
1211
+ });
1212
+ try {
1213
+ const headers = {
1214
+ accept: "application/json",
1215
+ "user-agent": options.userAgent ?? defaultUserAgent,
1216
+ "cosy-version": "1.0.1",
1217
+ "cosy-clienttype": "5",
1218
+ ...options.headers
1219
+ };
1220
+ if (options.token) headers.authorization = `Bearer ${options.token}`;
1221
+ if (options.machineId) {
1222
+ headers["Cosy-MachineToken"] = options.machineId;
1223
+ headers["Cosy-MachineType"] = "host";
1224
+ }
1225
+ let bodyText;
1226
+ if (options.body !== void 0) {
1227
+ headers["content-type"] = "application/json";
1228
+ bodyText = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
1229
+ }
1230
+ const response = await fetchImpl(options.url, {
1231
+ method,
1232
+ headers,
1233
+ ...bodyText === void 0 ? {} : { body: bodyText },
1234
+ signal: requestSignal
1235
+ });
1236
+ options.logger?.debug?.(`[Qoder ${options.operation}] Request completed`, {
1237
+ status: response.status,
1238
+ statusText: response.statusText,
1239
+ durationMs: Math.round(performance.now() - startedAt),
1240
+ ...qoderRequestId(response.headers) === void 0 ? {} : { requestId: qoderRequestId(response.headers) }
1241
+ });
1242
+ const text = await readLimitedText(response, response.ok ? defaultMaxJsonBytes : defaultMaxErrorBytes, `Qoder ${options.operation} response`);
1243
+ if (!response.ok) {
1244
+ options.logger?.error?.(`[Qoder ${options.operation}] Request failed`, redactLogPayload(text));
1245
+ throw qoderHttpError(`Failed to execute Qoder ${options.operation} with status ${response.status}.`, response);
1246
+ }
1247
+ let data;
1248
+ try {
1249
+ data = JSON.parse(text);
1250
+ } catch {
1251
+ options.logger?.error?.(`[Qoder ${options.operation}] Invalid JSON response`, redactLogPayload(text));
1252
+ throw new QoderLlmError(`Failed to parse Qoder ${options.operation} JSON response`, "MALFORMED_RESPONSE");
1253
+ }
1254
+ if (options.logCategory) logParsedResponse(options.logger, options.logCategory, data);
1255
+ return data;
1256
+ } catch (error) {
1257
+ if (error instanceof QoderLlmError) throw error;
1258
+ if (options.signal?.aborted) throw new QoderLlmError(`Qoder ${options.operation} request was aborted.`, "ABORTED");
1259
+ if (timeoutSignal.aborted) throw new QoderLlmError(`Qoder ${options.operation} request timed out.`, "TIMEOUT");
1260
+ throw new QoderLlmError(`Qoder ${options.operation} network request failed.`, "TRANSPORT", { cause: error });
1261
+ }
1262
+ }
1263
+ //#endregion
1264
+ //#region src/qoder/transport/auth.ts
1265
+ const expiryBufferMs = 3e5;
1266
+ const defaultExpiryMs = 864e5;
1267
+ const defaultAuthTimeoutMs = 15e3;
1268
+ function abortedError() {
1269
+ return new QoderLlmError("Qoder authentication was aborted.", "ABORTED");
1270
+ }
1271
+ async function waitForFlight(promise, signal) {
1272
+ if (signal === void 0) return promise;
1273
+ if (signal.aborted) throw abortedError();
1274
+ return new Promise((resolve, reject) => {
1275
+ const onAbort = () => reject(abortedError());
1276
+ signal.addEventListener("abort", onAbort, { once: true });
1277
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
1278
+ });
1279
+ }
1280
+ var QoderAuthService = class {
1281
+ cache = /* @__PURE__ */ new Map();
1282
+ inFlight = /* @__PURE__ */ new Map();
1283
+ fetchImpl;
1284
+ timeoutMs;
1285
+ resolveMachineId;
1286
+ region;
1287
+ logger;
1288
+ constructor(options = {}) {
1289
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
1290
+ this.timeoutMs = options.timeoutMs ?? defaultAuthTimeoutMs;
1291
+ this.resolveMachineId = options.resolveMachineId ?? getMachineId;
1292
+ this.region = options.region ?? "global";
1293
+ this.logger = options.logger;
1294
+ }
1295
+ clear(pat) {
1296
+ if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
1297
+ else this.cache.clear();
1298
+ }
1299
+ async getCredentials(pat, signal) {
1300
+ if (!pat || typeof pat !== "string") throw new QoderLlmError("Qoder Personal Access Token is missing or invalid. Configure Qoder in the Qoder settings page.", "MISSING_CREDENTIAL");
1301
+ if (signal?.aborted) throw abortedError();
1302
+ const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
1303
+ const cached = this.cache.get(cacheKey);
1304
+ if (cached && cached.expiresAt > Date.now() + expiryBufferMs) return cached.creds;
1305
+ let entry = this.inFlight.get(cacheKey);
1306
+ if (entry === void 0 || entry.controller.signal.aborted) {
1307
+ const controller = new AbortController();
1308
+ const created = {};
1309
+ created.controller = controller;
1310
+ created.waiters = 0;
1311
+ created.settled = false;
1312
+ created.timeout = setTimeout(() => controller.abort("authentication timeout"), this.timeoutMs);
1313
+ created.promise = this.exchangeAndResolve(pat, controller.signal).finally(() => {
1314
+ created.settled = true;
1315
+ clearTimeout(created.timeout);
1316
+ if (this.inFlight.get(cacheKey) === created) this.inFlight.delete(cacheKey);
1317
+ });
1318
+ entry = created;
1319
+ this.inFlight.set(cacheKey, entry);
1320
+ }
1321
+ entry.waiters++;
1322
+ try {
1323
+ return await waitForFlight(entry.promise, signal);
1324
+ } finally {
1325
+ entry.waiters--;
1326
+ if (entry.waiters === 0 && !entry.settled) {
1327
+ if (this.inFlight.get(cacheKey) === entry) this.inFlight.delete(cacheKey);
1328
+ entry.controller.abort("all callers aborted");
1329
+ }
1330
+ }
1331
+ }
1332
+ async exchangeAndResolve(pat, signal) {
1333
+ let jobToken;
1334
+ let expiresAt = Date.now() + defaultExpiryMs;
1335
+ const data = await openApiJsonRequest(this.fetchImpl, {
1336
+ url: getQoderExchangeUrl(this.region),
1337
+ body: { personal_token: pat },
1338
+ signal,
1339
+ timeoutMs: this.timeoutMs,
1340
+ logger: this.logger,
1341
+ operation: "Auth",
1342
+ logCategory: "auth.exchange"
1343
+ });
1344
+ if (!data.token) throw new QoderLlmError("Qoder PAT exchange returned no job token.", "AUTH");
1345
+ jobToken = data.token;
1346
+ if (data.expires_at) {
1347
+ const parsed = Date.parse(data.expires_at);
1348
+ if (!Number.isNaN(parsed)) expiresAt = parsed;
1349
+ } else if (typeof data.expires_in === "number" && data.expires_in > 0) expiresAt = Date.now() + data.expires_in;
1350
+ const userInfo = await retryMetadataRead(signal, () => this.fetchUserInfo(jobToken, signal));
1351
+ const creds = {
1352
+ userID: userInfo.userID,
1353
+ authToken: jobToken,
1354
+ name: userInfo.name || "Qoder User",
1355
+ email: userInfo.email,
1356
+ machineID: this.resolveMachineId()
1357
+ };
1358
+ const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
1359
+ this.cache.set(cacheKey, {
1360
+ creds,
1361
+ expiresAt
1362
+ });
1363
+ return creds;
1364
+ }
1365
+ async fetchUserInfo(jobToken, signal) {
1366
+ const info = await openApiJsonRequest(this.fetchImpl, {
1367
+ url: getQoderUserInfoUrl(this.region),
1368
+ token: jobToken,
1369
+ signal,
1370
+ timeoutMs: this.timeoutMs,
1371
+ logger: this.logger,
1372
+ operation: "UserInfo",
1373
+ logCategory: "auth.user-info"
1374
+ });
1375
+ if (!info.id) throw new QoderLlmError("Qoder identity lookup returned no user id.", "AUTH");
1376
+ return {
1377
+ userID: info.id,
1378
+ email: info.email ?? "",
1379
+ name: info.name ?? info.username ?? ""
1380
+ };
1381
+ }
1382
+ };
1383
+ //#endregion
1384
+ //#region src/qoder/catalog.ts
1385
+ const reasoningEffortOrder = /* @__PURE__ */ new Map([
1386
+ ["low", 0],
1387
+ ["medium", 1],
1388
+ ["high", 2],
1389
+ ["xhigh", 3],
1390
+ ["max", 4]
1391
+ ]);
1392
+ function positiveNumber(value) {
1393
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1394
+ }
1395
+ function contextOptionsOf(value) {
1396
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1397
+ const options = {};
1398
+ for (const [key, raw] of Object.entries(value)) {
1399
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
1400
+ const entry = raw;
1401
+ const tokenCount = positiveNumber(entry.token_count);
1402
+ const isDefault = typeof entry.is_default === "boolean" ? entry.is_default : void 0;
1403
+ if (tokenCount === void 0) continue;
1404
+ options[key] = {
1405
+ ...tokenCount === void 0 ? {} : { tokenCount },
1406
+ ...isDefault === void 0 ? {} : { isDefault }
1407
+ };
1408
+ }
1409
+ if (Object.keys(options).length === 0) return void 0;
1410
+ return options;
1411
+ }
1412
+ function reasoningEffortsOf(value) {
1413
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
1414
+ const enabled = value.enabled;
1415
+ if (typeof enabled !== "object" || enabled === null || Array.isArray(enabled)) return {};
1416
+ const rawEfforts = enabled.efforts;
1417
+ if (typeof rawEfforts !== "object" || rawEfforts === null || Array.isArray(rawEfforts)) return {};
1418
+ const efforts = [];
1419
+ let defaultEffort;
1420
+ for (const [id, raw] of Object.entries(rawEfforts)) {
1421
+ if (!id.trim() || typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
1422
+ const entry = raw;
1423
+ efforts.push({
1424
+ id,
1425
+ name: id,
1426
+ ...typeof entry.description === "string" && entry.description.trim() ? { description: entry.description.trim() } : {}
1427
+ });
1428
+ if (entry.is_default === true) defaultEffort = id;
1429
+ }
1430
+ return {
1431
+ ...efforts.length === 0 ? {} : { efforts: efforts.sort((left, right) => (reasoningEffortOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (reasoningEffortOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER)) },
1432
+ ...defaultEffort === void 0 ? {} : { defaultEffort }
1433
+ };
1434
+ }
1435
+ function thinkingDefault(value, fallback, onConflict) {
1436
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fallback;
1437
+ const config = value;
1438
+ const isDefault = (entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry) && entry.is_default === true;
1439
+ const enabled = isDefault(config.enabled);
1440
+ const disabled = isDefault(config.disabled);
1441
+ if (enabled && disabled) onConflict?.("thinking-defaults");
1442
+ return enabled === disabled ? fallback : enabled;
1443
+ }
1444
+ function normalizeQoderModels(payload, onConflict) {
1445
+ if (typeof payload !== "object" || payload === null || !Array.isArray(payload.assistant)) return [];
1446
+ const models = [];
1447
+ const seen = /* @__PURE__ */ new Set();
1448
+ for (const raw of payload.assistant) {
1449
+ if (typeof raw !== "object" || raw === null || raw.enable !== true) continue;
1450
+ const id = typeof raw.key === "string" ? raw.key.trim() : "";
1451
+ if (!id || seen.has(id)) continue;
1452
+ seen.add(id);
1453
+ const contextOptions = contextOptionsOf(raw.context_config);
1454
+ const defaultOptions = Object.values(contextOptions ?? {}).filter((option) => option.isDefault && option.tokenCount !== void 0);
1455
+ if (defaultOptions.length > 1) onConflict?.("context-defaults");
1456
+ const contextWindow = (defaultOptions.length === 1 ? defaultOptions[0]?.tokenCount : void 0) ?? positiveNumber(raw.max_input_tokens) ?? 18e4;
1457
+ const maxContextWindow = Math.max(positiveNumber(raw.max_input_tokens) ?? 0, contextWindow, ...Object.values(contextOptions ?? {}).map((option) => option.tokenCount ?? 0));
1458
+ const isReasoning = thinkingDefault(raw.thinking_config, raw.is_reasoning === true, onConflict);
1459
+ const priceFactor = typeof raw.price_factor === "number" && Number.isFinite(raw.price_factor) && raw.price_factor >= 0 ? raw.price_factor : void 0;
1460
+ const reasoning = reasoningEffortsOf(raw.thinking_config);
1461
+ models.push({
1462
+ id,
1463
+ name: typeof raw.display_name === "string" && raw.display_name.trim() ? raw.display_name.trim() : id,
1464
+ contextWindow,
1465
+ maxContextWindow,
1466
+ maxTokens: positiveNumber(raw.max_output_tokens) ?? 32768,
1467
+ source: typeof raw.source === "string" && raw.source.trim() ? raw.source.trim() : "system",
1468
+ isReasoning,
1469
+ supportsEffort: reasoning.efforts !== void 0,
1470
+ supportsImages: raw.is_vl === true,
1471
+ ...reasoning.efforts === void 0 ? {} : { reasoningEfforts: reasoning.efforts },
1472
+ ...reasoning.defaultEffort === void 0 ? {} : { defaultReasoningEffort: reasoning.defaultEffort },
1473
+ ...priceFactor === void 0 ? {} : { priceFactor },
1474
+ ...contextOptions === void 0 ? {} : { contextOptions }
1475
+ });
1476
+ }
1477
+ return models;
1478
+ }
1479
+ //#endregion
1480
+ //#region src/qoder/transport/catalog-reader.ts
1481
+ async function fetchQoderModels(credentials, options = {}) {
1482
+ const url = getQoderModelListUrl(options.region);
1483
+ const fetchImpl = options.fetch ?? globalThis.fetch;
1484
+ const startedAt = performance.now();
1485
+ const deadline = withDeadline(options.signal, options.timeoutMs ?? 15e3);
1486
+ options.logger?.debug?.("[Qoder Models] Requesting model catalog", { url });
1487
+ try {
1488
+ const response = await fetchImpl(url, {
1489
+ method: "GET",
1490
+ headers: {
1491
+ accept: "application/json",
1492
+ ...buildAuthHeaders(null, url, credentials)
1493
+ },
1494
+ signal: deadline.signal
1495
+ });
1496
+ options.logger?.debug?.("[Qoder Models] Catalog request completed", {
1497
+ url,
1498
+ status: response.status,
1499
+ durationMs: Math.round(performance.now() - startedAt),
1500
+ ...qoderRequestId(response.headers) === void 0 ? {} : { requestId: qoderRequestId(response.headers) }
1501
+ });
1502
+ const text = await readLimitedText(response, response.ok ? defaultMaxJsonBytes : defaultMaxErrorBytes, "Qoder model discovery response");
1503
+ if (!response.ok) {
1504
+ options.logger?.error?.("[Qoder Models] Catalog request failed", redactLogPayload(text));
1505
+ throw qoderHttpError(`Qoder model discovery failed with HTTP status ${response.status}.`, response);
1506
+ }
1507
+ let payload;
1508
+ try {
1509
+ payload = JSON.parse(text);
1510
+ } catch {
1511
+ throw new QoderLlmError("Qoder model discovery returned invalid JSON.", "MALFORMED_RESPONSE");
1512
+ }
1513
+ const models = normalizeQoderModels(payload, (conflict) => {
1514
+ options.logger?.warn?.("[Qoder Models] Conflicting catalog defaults; using fallback", { conflict });
1515
+ });
1516
+ if (models.length === 0) throw new QoderLlmError("Qoder model discovery returned no enabled models.", "EMPTY_RESPONSE");
1517
+ return models;
1518
+ } catch (error) {
1519
+ if (error instanceof QoderLlmError) throw error;
1520
+ if (options.signal?.aborted) throw new QoderLlmError("Qoder model discovery was aborted.", "ABORTED");
1521
+ if (deadline.timeoutSignal.aborted) throw new QoderLlmError("Qoder model discovery timed out.", "TIMEOUT");
1522
+ options.logger?.error?.("[Qoder Models] Catalog network request failed", redactLogValue(error));
1523
+ throw new QoderLlmError("Qoder model discovery network request failed.", "TRANSPORT", { cause: error });
1524
+ }
1525
+ }
1526
+ //#endregion
1527
+ //#region src/qoder/transport/wire/translate.ts
1528
+ function unsupported(message) {
1529
+ return new QoderLlmError(message, "UNSUPPORTED_CONTENT");
1530
+ }
1531
+ function toolResultText(block) {
1532
+ let text = "";
1533
+ for (const nested of block.content) {
1534
+ if (nested.type === "image") continue;
1535
+ if (nested.type !== "text") throw unsupported(`Qoder tool results support text only; received nested ${String(nested.type)} content.`);
1536
+ text += nested.text;
1537
+ }
1538
+ return text;
1539
+ }
1540
+ /**
1541
+ * Check message shapes without performing any provider I/O.
1542
+ *
1543
+ * Callers run this before resolving credentials so an invalid request never
1544
+ * consumes a Qoder subscription.
1545
+ */
1546
+ function validateMessageShapes(messages) {
1547
+ for (const message of messages) {
1548
+ const toolResults = message.content.filter((block) => block.type === "tool-result");
1549
+ if (toolResults.length > 0) {
1550
+ if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
1551
+ for (const result of toolResults) toolResultText(result);
1552
+ continue;
1553
+ }
1554
+ for (const block of message.content) {
1555
+ if (block.type === "text") continue;
1556
+ if (block.type === "image") {
1557
+ if (message.role !== "user") throw unsupported("Qoder image content is valid only in user messages.");
1558
+ continue;
1559
+ }
1560
+ if (block.type === "tool-call") {
1561
+ if (message.role !== "assistant") throw unsupported("Qoder tool calls are valid only in assistant messages.");
1562
+ continue;
1563
+ }
1564
+ if (block.type === "reasoning") continue;
1565
+ throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
1566
+ }
1567
+ }
1568
+ }
1569
+ /** Reject a batch that exceeds the deployment image policy before any upload work starts. */
1570
+ function enforceImageLimits(images, attachments) {
1571
+ const limits = attachments.imageLimits;
1572
+ if (images.length > limits.maxImagesPerMessage) throw unsupported(`Qoder accepts at most ${limits.maxImagesPerMessage} images per message; received ${images.length}.`);
1573
+ let total = 0;
1574
+ for (const image of images) total += image.attachment.bytes;
1575
+ if (total > limits.maxMessageImageBytes) throw unsupported("Qoder message image content exceeds the configured total byte limit.");
1576
+ }
1577
+ async function resolveImagePart(block, context) {
1578
+ const { attachments, uploader, credentials, signal } = context;
1579
+ if (attachments === void 0) throw new QoderLlmError("Qoder image input requires the DSH attachment service.", "ATTACHMENT");
1580
+ let image;
1581
+ try {
1582
+ const limits = attachments.imageLimits;
1583
+ image = await attachments.readImageRequest(block.attachment, {
1584
+ maxPixels: limits.maxImagePixels,
1585
+ maxBytes: limits.maxImageBytes
1586
+ }, signal);
1587
+ } catch (error) {
1588
+ if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
1589
+ if (error instanceof QoderLlmError) throw error;
1590
+ throw new QoderLlmError("Qoder could not prepare an image attachment.", "ATTACHMENT", { cause: error });
1591
+ }
1592
+ if (uploader !== void 0 && credentials !== void 0) return {
1593
+ type: "image_url",
1594
+ image_url: { url: await uploader.resolveImageUrl(image, credentials, signal) }
1595
+ };
1596
+ return {
1597
+ type: "image_url",
1598
+ image_url: { url: `data:${image.mediaType};base64,${Buffer.from(image.data).toString("base64")}` }
1599
+ };
1600
+ }
1601
+ function translateTools(tools) {
1602
+ return (tools ?? []).map((tool) => ({
1603
+ type: "function",
1604
+ function: {
1605
+ name: tool.name,
1606
+ description: tool.description,
1607
+ parameters: tool.parameters
1608
+ }
1609
+ }));
1610
+ }
1611
+ async function validateAndTranslateMessages(messages, systemPrompt, attachments, signal, pipeline) {
1612
+ validateMessageShapes(messages);
1613
+ const context = {
1614
+ attachments,
1615
+ signal,
1616
+ uploader: pipeline?.uploader,
1617
+ credentials: pipeline?.credentials
1618
+ };
1619
+ const preserveThinking = pipeline?.preserveThinking ?? true;
1620
+ const output = [];
1621
+ if (typeof systemPrompt === "string" && systemPrompt.trim().length > 0) output.push({
1622
+ role: "system",
1623
+ content: systemPrompt
1624
+ });
1625
+ for (const message of messages) {
1626
+ const toolResults = message.content.filter((block) => block.type === "tool-result");
1627
+ if (toolResults.length > 0) {
1628
+ if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
1629
+ for (const result of toolResults) {
1630
+ output.push({
1631
+ role: "tool",
1632
+ tool_call_id: String(result.toolCallId),
1633
+ content: toolResultText(result)
1634
+ });
1635
+ const images = result.content.filter((block) => block.type === "image");
1636
+ if (images.length > 0) {
1637
+ if (attachments !== void 0) enforceImageLimits(images, attachments);
1638
+ output.push({
1639
+ role: "user",
1640
+ content: [{
1641
+ type: "text",
1642
+ text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
1643
+ }, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
1644
+ });
1645
+ }
1646
+ }
1647
+ continue;
1648
+ }
1649
+ let text = "";
1650
+ let reasoningText = "";
1651
+ const userContent = [];
1652
+ const pendingImages = [];
1653
+ let hasImage = false;
1654
+ const toolCalls = [];
1655
+ for (const block of message.content) {
1656
+ if (block.type === "text") {
1657
+ text += block.text;
1658
+ if (message.role === "user") userContent.push({
1659
+ type: "text",
1660
+ text: block.text
1661
+ });
1662
+ continue;
1663
+ }
1664
+ if (block.type === "image") {
1665
+ hasImage = true;
1666
+ pendingImages.push({
1667
+ slot: userContent.length,
1668
+ block
1669
+ });
1670
+ userContent.push(void 0);
1671
+ continue;
1672
+ }
1673
+ if (block.type === "tool-call") {
1674
+ toolCalls.push({
1675
+ id: String(block.id),
1676
+ type: "function",
1677
+ function: {
1678
+ name: block.name,
1679
+ arguments: block.arguments
1680
+ }
1681
+ });
1682
+ continue;
1683
+ }
1684
+ if (block.type === "reasoning") {
1685
+ if (message.role === "assistant") reasoningText += block.text;
1686
+ continue;
1687
+ }
1688
+ }
1689
+ if (message.role === "assistant") {
1690
+ const hasReasoning = preserveThinking && reasoningText.length > 0;
1691
+ if (!text && toolCalls.length === 0 && !hasReasoning) continue;
1692
+ output.push({
1693
+ role: "assistant",
1694
+ content: text || " ",
1695
+ ...toolCalls.length === 0 ? {} : { tool_calls: toolCalls },
1696
+ ...hasReasoning ? { reasoning_content: reasoningText } : {}
1697
+ });
1698
+ continue;
1699
+ }
1700
+ if (pendingImages.length > 0) {
1701
+ if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
1702
+ await Promise.all(pendingImages.map(async (pending) => {
1703
+ userContent[pending.slot] = await resolveImagePart(pending.block, context);
1704
+ }));
1705
+ }
1706
+ output.push({
1707
+ role: message.role,
1708
+ content: hasImage ? userContent.filter((part) => part !== void 0) : text
1709
+ });
1710
+ }
1711
+ return output;
1712
+ }
1713
+ //#endregion
1714
+ //#region src/qoder/transport/wire/serialize.ts
1715
+ /** Build the minimum qodercli request envelope for a validated DSH request. */
1716
+ function stableHash(prefix, ...inputs) {
1717
+ const hash = crypto.createHash("sha256");
1718
+ hash.update(prefix);
1719
+ for (const input of inputs) {
1720
+ hash.update("\0");
1721
+ hash.update(input);
1722
+ }
1723
+ return hash.digest("hex").slice(0, 16);
1724
+ }
1725
+ function stableChatRecordId(model, messages, tools, maxTokens) {
1726
+ const hash = crypto.createHash("sha256");
1727
+ hash.update("qoder-record");
1728
+ hash.update("\0");
1729
+ hash.update(model);
1730
+ hash.update("\0");
1731
+ hash.update(JSON.stringify(messages));
1732
+ hash.update("\0");
1733
+ hash.update(JSON.stringify(tools));
1734
+ hash.update("\0");
1735
+ hash.update(`mt=${maxTokens}`);
1736
+ return hash.digest("hex").slice(0, 16);
1737
+ }
1738
+ /**
1739
+ * Reject an unusable request before any credential resolution or provider I/O.
1740
+ *
1741
+ * Image publication needs credentials, so message translation now runs after
1742
+ * authentication. This static pass preserves the guarantee that a request the
1743
+ * provider cannot serve never reaches the network.
1744
+ */
1745
+ function validateQoderRequestShape(options, model) {
1746
+ if (options.reasoningEffort !== void 0) {
1747
+ const effort = String(options.reasoningEffort);
1748
+ if (!model?.reasoningEfforts?.some((candidate) => candidate.id === effort)) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise reasoning effort "${effort}".`, "UNSUPPORTED_REASONING_EFFORT");
1749
+ }
1750
+ if (options.messages.some((message) => contentHasImage(message.content)) && model?.supportsImages !== true) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise image input.`, "UNSUPPORTED_CONTENT");
1751
+ validateMessageShapes(options.messages);
1752
+ }
1753
+ /** Translate a request whose shape has already been validated. */
1754
+ function translateQoderMessages(options, attachments, pipeline) {
1755
+ return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal, pipeline);
1756
+ }
1757
+ async function validateQoderRequest(options, model, attachments) {
1758
+ validateQoderRequestShape(options, model);
1759
+ return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal);
1760
+ }
1761
+ async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments) {
1762
+ if (!userId) throw new QoderLlmError("Qoder request identity is missing.", "AUTH");
1763
+ const modelKey = options.model || "cmodel";
1764
+ const messages = translatedMessages ?? await validateQoderRequest(options, model, attachments);
1765
+ const modelMaxTokens = model?.maxTokens ?? 32768;
1766
+ const maxTokens = Math.min(options.maxTokens ?? modelMaxTokens, modelMaxTokens);
1767
+ const isReasoning = options.reasoningEffort !== void 0 || (model?.isReasoning ?? false);
1768
+ const tools = translateTools(options.tools);
1769
+ const defaultContexts = Object.values(model?.contextOptions ?? {}).filter((option) => option.isDefault === true && typeof option.tokenCount === "number" && Number.isFinite(option.tokenCount) && option.tokenCount > 0);
1770
+ const contextConfig = model?.contextOptions === void 0 || defaultContexts.length !== 1 ? void 0 : Object.fromEntries(Object.entries(model.contextOptions).map(([key, value]) => [key, {
1771
+ ...value.tokenCount === void 0 ? {} : { token_count: value.tokenCount },
1772
+ ...value.isDefault === void 0 ? {} : { is_default: value.isDefault }
1773
+ }]));
1774
+ let lastUserText = "";
1775
+ for (let index = messages.length - 1; index >= 0; index--) {
1776
+ const message = messages[index];
1777
+ if (message === void 0) continue;
1778
+ if (message.role === "user") {
1779
+ const content = message.content;
1780
+ lastUserText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
1781
+ break;
1782
+ }
1783
+ }
1784
+ const stablePart = stableHash("qoder-session", userId, modelKey);
1785
+ const sessionId = options.sessionId === void 0 ? `${stablePart}-${crypto.randomUUID()}` : `${stablePart}-${String(options.sessionId)}`;
1786
+ const recordId = stableChatRecordId(modelKey, messages, tools, maxTokens);
1787
+ return {
1788
+ request_id: crypto.randomUUID(),
1789
+ request_set_id: recordId,
1790
+ chat_record_id: recordId,
1791
+ session_id: sessionId,
1792
+ stream: true,
1793
+ chat_task: "FREE_INPUT",
1794
+ is_reply: true,
1795
+ is_retry: false,
1796
+ source: 1,
1797
+ version: "3",
1798
+ session_type: "qodercli",
1799
+ agent_id: "agent_common",
1800
+ task_id: "common",
1801
+ code_language: "",
1802
+ chat_prompt: "",
1803
+ image_urls: null,
1804
+ aliyun_user_type: "",
1805
+ system: "",
1806
+ messages,
1807
+ tools,
1808
+ parameters: {
1809
+ max_tokens: maxTokens,
1810
+ ...options.reasoningEffort === void 0 ? {} : { reasoning_effort: String(options.reasoningEffort) }
1811
+ },
1812
+ chat_context: {
1813
+ chatPrompt: "",
1814
+ imageUrls: null,
1815
+ extra: {
1816
+ context: [],
1817
+ modelConfig: {
1818
+ key: modelKey,
1819
+ is_reasoning: isReasoning
1820
+ },
1821
+ originalContent: lastUserText
1822
+ },
1823
+ features: [],
1824
+ text: lastUserText
1825
+ },
1826
+ model_config: {
1827
+ key: modelKey,
1828
+ is_reasoning: isReasoning,
1829
+ max_output_tokens: maxTokens,
1830
+ source: model?.source || "system",
1831
+ ...contextConfig === void 0 ? {} : { context_config: contextConfig }
1832
+ },
1833
+ business: {
1834
+ product: "cli",
1835
+ version: "1.0.0",
1836
+ type: "agent",
1837
+ stage: "start",
1838
+ id: crypto.randomUUID(),
1839
+ name: lastUserText.substring(0, 30),
1840
+ begin_at: Date.now()
1841
+ }
1842
+ };
1843
+ }
1844
+ const mediaTypeExtensions = {
1845
+ "image/png": "png",
1846
+ "image/jpeg": "jpg",
1847
+ "image/webp": "webp",
1848
+ "image/gif": "gif"
1849
+ };
1850
+ function aborted$2() {
1851
+ return new QoderLlmError("Qoder image upload was aborted.", "ABORTED");
1852
+ }
1853
+ /** Encode one image as a single-field multipart payload using the Qoder boundary shape. */
1854
+ function buildQoderImageMultipart(data, mediaType, boundaryId = crypto.randomUUID()) {
1855
+ const boundary = `----qodercli-${boundaryId}`;
1856
+ const extension = mediaTypeExtensions[mediaType] ?? "png";
1857
+ const header = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="image.${extension}"\r\nContent-Type: ${mediaType}\r\n\r\n`, "utf8");
1858
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
1859
+ return {
1860
+ body: Buffer.concat([
1861
+ header,
1862
+ Buffer.from(data),
1863
+ footer
1864
+ ]),
1865
+ boundary
1866
+ };
1867
+ }
1868
+ /**
1869
+ * Extract the durable object URL from a center upload response.
1870
+ *
1871
+ * @param payload - parsed response body.
1872
+ * @returns the first usable absolute URL, or undefined when none is present.
1873
+ */
1874
+ function readQoderImageUrl(payload) {
1875
+ if (payload === null || typeof payload !== "object") return void 0;
1876
+ const root = payload;
1877
+ const result = root.result;
1878
+ const data = root.data;
1879
+ const candidates = [
1880
+ root.url,
1881
+ result?.url,
1882
+ result?.oss_url,
1883
+ data?.url,
1884
+ data?.oss_url
1885
+ ];
1886
+ for (const candidate of candidates) {
1887
+ if (typeof candidate !== "string") continue;
1888
+ const trimmed = candidate.trim();
1889
+ if (trimmed.length === 0) continue;
1890
+ if (!URL.canParse(trimmed)) continue;
1891
+ return trimmed;
1892
+ }
1893
+ }
1894
+ function dataUrl(image) {
1895
+ return `data:${image.mediaType};base64,${Buffer.from(image.data).toString("base64")}`;
1896
+ }
1897
+ /**
1898
+ * Publishes request images to the Qoder center service and remembers the result.
1899
+ *
1900
+ * Publication never fails a model request: any upstream problem degrades to an
1901
+ * inline data URL so the turn proceeds with the same content it would have
1902
+ * carried before the pipeline existed.
1903
+ */
1904
+ var QoderImageUploader = class {
1905
+ cache = /* @__PURE__ */ new Map();
1906
+ flights = new SingleFlight();
1907
+ fetchImpl;
1908
+ region;
1909
+ logger;
1910
+ timeoutMs;
1911
+ cacheTtlMs;
1912
+ cacheCapacity;
1913
+ maxConcurrency;
1914
+ refreshCredentials;
1915
+ now;
1916
+ active = 0;
1917
+ queue = [];
1918
+ constructor(options = {}) {
1919
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
1920
+ this.region = options.region ?? "global";
1921
+ this.logger = options.logger;
1922
+ this.timeoutMs = options.timeoutMs ?? 3e4;
1923
+ this.cacheTtlMs = options.cacheTtlMs ?? 18e5;
1924
+ this.cacheCapacity = options.cacheCapacity ?? 512;
1925
+ this.maxConcurrency = options.maxConcurrency ?? 4;
1926
+ this.refreshCredentials = options.refreshCredentials;
1927
+ this.now = options.now ?? Date.now;
1928
+ }
1929
+ /**
1930
+ * Resolve the wire URL for one request image.
1931
+ *
1932
+ * @param image - request-encoded image produced by the DSH attachment store.
1933
+ * @param credentials - credentials authorizing the center exchange.
1934
+ * @param signal - optional caller cancellation.
1935
+ * @returns a center object URL, or an inline data URL when publication fails.
1936
+ * @throws QoderLlmError with code `ABORTED` only when the caller cancels.
1937
+ */
1938
+ async resolveImageUrl(image, credentials, signal) {
1939
+ if (signal?.aborted) throw aborted$2();
1940
+ const key = this.cacheKey(image, credentials);
1941
+ const cached = this.cache.get(key);
1942
+ if (cached !== void 0) {
1943
+ if (cached.expiresAt > this.now()) {
1944
+ this.cache.delete(key);
1945
+ this.cache.set(key, cached);
1946
+ this.logger?.debug?.("[image-upload] cache hit", {
1947
+ url: cached.url,
1948
+ region: this.region,
1949
+ mediaType: image.mediaType,
1950
+ bytes: image.data.byteLength
1951
+ });
1952
+ return cached.url;
1953
+ }
1954
+ this.cache.delete(key);
1955
+ }
1956
+ return this.flights.run(key, signal, async (sharedSignal) => {
1957
+ const url = await this.publish(image, credentials, sharedSignal);
1958
+ if (url !== void 0) {
1959
+ this.remember(key, url);
1960
+ return url;
1961
+ }
1962
+ return dataUrl(image);
1963
+ }, aborted$2);
1964
+ }
1965
+ /**
1966
+ * Derive the deduplication identity for one request image.
1967
+ *
1968
+ * `variantId` is the attachment store's deterministic cache and upload index
1969
+ * over the attachment, the request policy, and the encoder parameters, so it
1970
+ * identifies these exact bytes without rehashing them. Region and subscriber
1971
+ * are included because a center object is never shared across either.
1972
+ */
1973
+ cacheKey(image, credentials) {
1974
+ const hash = crypto.createHash("sha256");
1975
+ hash.update(getQoderImageUploadUrl(this.region));
1976
+ hash.update("\0");
1977
+ hash.update(credentials.userID);
1978
+ hash.update("\0");
1979
+ hash.update(image.mediaType);
1980
+ hash.update("\0");
1981
+ const variantId = typeof image.variantId === "string" ? image.variantId : "";
1982
+ if (variantId.length > 0) hash.update(variantId);
1983
+ else hash.update(crypto.createHash("sha256").update(Buffer.from(image.data)).digest("hex"));
1984
+ return hash.digest("hex");
1985
+ }
1986
+ remember(key, url) {
1987
+ this.cache.delete(key);
1988
+ this.cache.set(key, {
1989
+ url,
1990
+ expiresAt: this.now() + this.cacheTtlMs
1991
+ });
1992
+ while (this.cache.size > this.cacheCapacity) {
1993
+ const oldest = this.cache.keys().next();
1994
+ if (oldest.done === true) break;
1995
+ this.cache.delete(oldest.value);
1996
+ }
1997
+ }
1998
+ async withSlot(signal, operation) {
1999
+ if (signal.aborted) throw aborted$2();
2000
+ if (this.active >= this.maxConcurrency) {
2001
+ this.logger?.debug?.("[image-upload] queued", {
2002
+ region: this.region,
2003
+ active: this.active,
2004
+ queued: this.queue.length + 1
2005
+ });
2006
+ await new Promise((resolve, reject) => {
2007
+ const onAbort = () => {
2008
+ const index = this.queue.indexOf(release);
2009
+ if (index !== -1) this.queue.splice(index, 1);
2010
+ reject(aborted$2());
2011
+ };
2012
+ const release = () => {
2013
+ signal.removeEventListener("abort", onAbort);
2014
+ resolve();
2015
+ };
2016
+ signal.addEventListener("abort", onAbort, { once: true });
2017
+ this.queue.push(release);
2018
+ });
2019
+ } else this.active++;
2020
+ try {
2021
+ if (signal.aborted) throw aborted$2();
2022
+ return await operation();
2023
+ } finally {
2024
+ const next = this.queue.shift();
2025
+ if (next !== void 0) next();
2026
+ else this.active--;
2027
+ }
2028
+ }
2029
+ /** Attempt publication, returning undefined when the request must degrade. */
2030
+ async publish(image, credentials, signal) {
2031
+ return this.withSlot(signal, async () => {
2032
+ const first = await this.attempt(image, credentials, signal);
2033
+ if (first.url !== void 0) return first.url;
2034
+ if (!first.retryable || this.refreshCredentials === void 0) {
2035
+ this.warnDegraded(first.reason);
2036
+ return;
2037
+ }
2038
+ let refreshed;
2039
+ this.logger?.debug?.("[image-upload] refreshing credentials before retry", {
2040
+ region: this.region,
2041
+ reason: first.reason
2042
+ });
2043
+ try {
2044
+ refreshed = await this.refreshCredentials(signal);
2045
+ } catch (error) {
2046
+ if (signal.aborted) throw aborted$2();
2047
+ this.warnDegraded("credential refresh failed", error);
2048
+ return;
2049
+ }
2050
+ const second = await this.attempt(image, refreshed, signal);
2051
+ if (second.url !== void 0) return second.url;
2052
+ this.warnDegraded(second.reason);
2053
+ });
2054
+ }
2055
+ warnDegraded(reason, cause) {
2056
+ this.logger?.warn?.("[image-upload] upload failed, keeping base64 image", {
2057
+ region: this.region,
2058
+ reason
2059
+ }, ...cause === void 0 ? [] : [redactLogValue(cause)]);
2060
+ }
2061
+ async attempt(image, credentials, signal) {
2062
+ const requestId = crypto.randomUUID();
2063
+ const url = getQoderImageUploadUrl(this.region, requestId);
2064
+ const multipart = buildQoderImageMultipart(image.data, image.mediaType);
2065
+ const signedBody = Buffer.from(String(multipart.body.length), "utf8");
2066
+ const timeout = AbortSignal.timeout(this.timeoutMs);
2067
+ const requestSignal = AbortSignal.any([signal, timeout]);
2068
+ const startedAt = this.now();
2069
+ const details = {
2070
+ requestId,
2071
+ region: this.region,
2072
+ mediaType: image.mediaType,
2073
+ bytes: image.data.byteLength,
2074
+ timeoutMs: this.timeoutMs
2075
+ };
2076
+ this.logger?.debug?.("[image-upload] started", details);
2077
+ try {
2078
+ const response = await this.fetchImpl(url, {
2079
+ method: "PUT",
2080
+ headers: {
2081
+ ...buildAuthHeaders(signedBody, url, credentials),
2082
+ "AI-CLIENT-TIMESTAMP": String(Math.floor(this.now() / 1e3)),
2083
+ "Content-Type": `multipart/form-data; boundary=${multipart.boundary}`,
2084
+ "Content-Length": String(multipart.body.length),
2085
+ "accept": "application/json"
2086
+ },
2087
+ body: new Uint8Array(multipart.body),
2088
+ signal: requestSignal
2089
+ });
2090
+ this.logger?.debug?.("[image-upload] response received", {
2091
+ requestId,
2092
+ status: response.status,
2093
+ elapsedMs: this.now() - startedAt
2094
+ });
2095
+ const text = await readLimitedText(response, defaultMaxJsonBytes, "Qoder image upload response");
2096
+ if (!response.ok) return {
2097
+ retryable: response.status === 401 || response.status === 403,
2098
+ reason: `HTTP ${response.status}`
2099
+ };
2100
+ let payload;
2101
+ try {
2102
+ payload = JSON.parse(text);
2103
+ } catch {
2104
+ return {
2105
+ retryable: false,
2106
+ reason: "invalid JSON response"
2107
+ };
2108
+ }
2109
+ const resolved = readQoderImageUrl(payload);
2110
+ if (resolved === void 0) return {
2111
+ retryable: false,
2112
+ reason: "response carried no image URL"
2113
+ };
2114
+ this.logger?.debug?.("[image-upload] succeeded", {
2115
+ ...details,
2116
+ status: response.status,
2117
+ elapsedMs: this.now() - startedAt,
2118
+ url: resolved
2119
+ });
2120
+ return {
2121
+ url: resolved,
2122
+ retryable: false,
2123
+ reason: ""
2124
+ };
2125
+ } catch (error) {
2126
+ const failureDetails = {
2127
+ requestId,
2128
+ region: this.region,
2129
+ elapsedMs: this.now() - startedAt
2130
+ };
2131
+ if (signal.aborted) {
2132
+ this.logger?.debug?.("[image-upload] aborted", failureDetails);
2133
+ throw aborted$2();
2134
+ }
2135
+ if (timeout.aborted) {
2136
+ this.logger?.debug?.("[image-upload] timed out", failureDetails);
2137
+ return {
2138
+ retryable: false,
2139
+ reason: "upload timed out"
2140
+ };
2141
+ }
2142
+ this.logger?.debug?.("[image-upload] network failure", failureDetails, redactLogValue(error));
2143
+ return {
2144
+ retryable: false,
2145
+ reason: "network failure"
2146
+ };
2147
+ }
2148
+ }
2149
+ };
2150
+ //#endregion
2151
+ //#region src/qoder/transport/account-reader.ts
2152
+ const defaultUsageTtlMs = 6e4;
2153
+ const defaultUsageTimeoutMs = 15e3;
2154
+ function normalizeQuota(raw) {
2155
+ if (!raw || typeof raw !== "object") return void 0;
2156
+ const total = typeof raw.total === "number" && Number.isFinite(raw.total) ? raw.total : typeof raw.cap === "number" && Number.isFinite(raw.cap) ? raw.cap : typeof raw.remaining === "number" && typeof raw.used === "number" ? raw.used + raw.remaining : 0;
2157
+ const used = typeof raw.used === "number" && Number.isFinite(raw.used) ? raw.used : 0;
2158
+ const remaining = typeof raw.remaining === "number" && Number.isFinite(raw.remaining) ? raw.remaining : Math.max(0, total - used);
2159
+ let percentage;
2160
+ if (typeof raw.percentage === "number" && Number.isFinite(raw.percentage)) percentage = raw.percentage <= 1 && total > 1 ? raw.percentage * 100 : raw.percentage;
2161
+ else percentage = total > 0 ? used / total * 100 : 0;
2162
+ const unit = typeof raw.unit === "string" && raw.unit.length > 0 ? raw.unit : "credits";
2163
+ return {
2164
+ total,
2165
+ used,
2166
+ remaining,
2167
+ percentage,
2168
+ unit
2169
+ };
2170
+ }
2171
+ function normalizeExpiresAt(rawExpires) {
2172
+ if (rawExpires === void 0 || rawExpires === null) return void 0;
2173
+ if (typeof rawExpires === "number" && rawExpires > 0) return new Date(rawExpires).toISOString();
2174
+ if (typeof rawExpires === "string" && rawExpires.length > 0) {
2175
+ const parsed = Date.parse(rawExpires);
2176
+ if (!Number.isNaN(parsed) && parsed > 0) return new Date(parsed).toISOString();
2177
+ }
2178
+ }
2179
+ function asString(value) {
2180
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2181
+ }
2182
+ function asBoolean(value) {
2183
+ return typeof value === "boolean" ? value : void 0;
2184
+ }
2185
+ function asNumber(value) {
2186
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2187
+ if (typeof value === "string" && value.trim().length > 0) {
2188
+ const num = Number(value);
2189
+ if (Number.isFinite(num)) return num;
2190
+ }
2191
+ }
2192
+ function normalizeOrganization(raw) {
2193
+ if (!raw || typeof raw !== "object") return void 0;
2194
+ const obj = raw;
2195
+ const orgId = asString(obj.org_id) ?? asString(obj.orgId) ?? asString(obj.id);
2196
+ const orgName = asString(obj.org_name) ?? asString(obj.orgName) ?? asString(obj.name);
2197
+ if (!orgId || !orgName) return void 0;
2198
+ return {
2199
+ orgId,
2200
+ orgName,
2201
+ ...asString(obj.role_name) ?? asString(obj.roleName) !== void 0 ? { roleName: asString(obj.role_name) ?? asString(obj.roleName) } : {},
2202
+ isSuspended: asBoolean(obj.is_suspended) ?? asBoolean(obj.isSuspended) ?? false,
2203
+ canManageSubscriptions: asBoolean(obj.can_manage_subscriptions) ?? asBoolean(obj.canManageSubscriptions) ?? false,
2204
+ resourcePackageFeatureEnabled: asBoolean(obj.resource_package_feature_enabled) ?? asBoolean(obj.resourcePackageFeatureEnabled) ?? false
2205
+ };
2206
+ }
2207
+ function normalizeFeatureAllowed(raw) {
2208
+ if (!raw || typeof raw !== "object") return void 0;
2209
+ const obj = raw;
2210
+ return {
2211
+ quest: asBoolean(obj.quest) ?? false,
2212
+ wiki: asBoolean(obj.wiki) ?? false,
2213
+ codeReview: asBoolean(obj.code_review) ?? asBoolean(obj.codeReview) ?? false
2214
+ };
2215
+ }
2216
+ function normalizePlan(raw) {
2217
+ if (!raw || typeof raw !== "object") return void 0;
2218
+ const obj = raw;
2219
+ const userType = asString(obj.user_type) ?? asString(obj.userType);
2220
+ const planTierName = asString(obj.plan_tier_name) ?? asString(obj.planTierName) ?? asString(obj.plan_name) ?? asString(obj.planName);
2221
+ if (!userType || !planTierName) return void 0;
2222
+ const organization = normalizeOrganization(obj.organization);
2223
+ const isPersonalVersion = asBoolean(obj.is_personal_version) ?? asBoolean(obj.isPersonalVersion) ?? organization === void 0;
2224
+ const startDate = normalizeExpiresAt(obj.start_date ?? obj.startDate);
2225
+ const endDate = normalizeExpiresAt(obj.end_date ?? obj.endDate);
2226
+ const planTier = asString(obj.plan_tier) ?? asString(obj.planTier);
2227
+ const isHighestTier = asBoolean(obj.is_highest_tier) ?? asBoolean(obj.isHighestTier);
2228
+ const isRenewed = asBoolean(obj.is_renewed) ?? asBoolean(obj.isRenewed);
2229
+ const featureAllowed = normalizeFeatureAllowed(obj.feature_allowed ?? obj.featureAllowed);
2230
+ return {
2231
+ userType,
2232
+ planTierName,
2233
+ ...planTier !== void 0 ? { planTier } : {},
2234
+ isPersonalVersion,
2235
+ ...isHighestTier !== void 0 ? { isHighestTier } : {},
2236
+ ...isRenewed !== void 0 ? { isRenewed } : {},
2237
+ ...startDate !== void 0 ? { startDate } : {},
2238
+ ...endDate !== void 0 ? { endDate } : {},
2239
+ ...organization !== void 0 ? { organization } : {},
2240
+ ...featureAllowed !== void 0 ? { featureAllowed } : {},
2241
+ raw
2242
+ };
2243
+ }
2244
+ function normalizeStatus(raw) {
2245
+ if (!raw || typeof raw !== "object") return void 0;
2246
+ const obj = raw;
2247
+ const featureSwitches = obj.featureSwitches ?? obj.feature_switches;
2248
+ const teamSwitches = obj.teamSwitches ?? obj.team_switches;
2249
+ const allowByok = asNumber(featureSwitches?.allow_byok ?? featureSwitches?.allowByok) ?? 0;
2250
+ const teamAllowByok = asNumber(teamSwitches?.allow_byok ?? teamSwitches?.allowByok);
2251
+ const isPrivacyPolicyModifiable = asBoolean(obj.isPrivacyPolicyModifiable ?? obj.is_data_policy_modifiable);
2252
+ return {
2253
+ allowByok,
2254
+ ...teamAllowByok !== void 0 ? { teamAllowByok } : {},
2255
+ ...isPrivacyPolicyModifiable !== void 0 ? { isPrivacyPolicyModifiable } : {},
2256
+ raw
2257
+ };
2258
+ }
2259
+ var QoderUsageReader = class {
2260
+ authService;
2261
+ fetchImpl;
2262
+ ttlMs;
2263
+ timeoutMs;
2264
+ region;
2265
+ logger;
2266
+ cache = /* @__PURE__ */ new Map();
2267
+ flights = new SingleFlight();
2268
+ constructor(options) {
2269
+ this.authService = options.authService;
2270
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
2271
+ this.ttlMs = options.ttlMs ?? defaultUsageTtlMs;
2272
+ this.timeoutMs = options.timeoutMs ?? defaultUsageTimeoutMs;
2273
+ this.region = options.region ?? "global";
2274
+ this.logger = options.logger;
2275
+ }
2276
+ async readAccount(pat, options) {
2277
+ if (!pat || typeof pat !== "string") throw new QoderLlmError("Qoder Personal Access Token is missing or invalid.", "MISSING_CREDENTIAL");
2278
+ const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
2279
+ if (!options?.force) {
2280
+ const cached = this.cache.get(cacheKey);
2281
+ if (cached && cached.expiresAt > Date.now()) return cached.info;
2282
+ }
2283
+ return this.flights.run(cacheKey, options?.signal, (sharedSignal) => this.loadAccount(pat, sharedSignal, cacheKey), () => new QoderLlmError("Qoder account request was aborted.", "ABORTED"));
2284
+ }
2285
+ async loadAccount(pat, signal, cacheKey) {
2286
+ const creds = await this.authService.getCredentials(pat, signal);
2287
+ const profile = {
2288
+ id: creds.userID,
2289
+ name: creds.name || "Qoder User",
2290
+ email: creds.email || ""
2291
+ };
2292
+ const [usage, plan, status] = await Promise.all([
2293
+ retryMetadataRead(signal, () => this.fetchUsage(creds.authToken, signal)),
2294
+ this.safeFetchPlan(creds.authToken, signal),
2295
+ this.safeFetchStatus(creds.authToken, creds.machineID, signal)
2296
+ ]);
2297
+ const accountInfo = {
2298
+ profile,
2299
+ usage,
2300
+ ...plan !== void 0 ? { plan } : {},
2301
+ ...status !== void 0 ? { status } : {},
2302
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2303
+ };
2304
+ this.cache.set(cacheKey, {
2305
+ info: accountInfo,
2306
+ expiresAt: Date.now() + this.ttlMs
2307
+ });
2308
+ return accountInfo;
2309
+ }
2310
+ clear(pat) {
2311
+ if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
2312
+ else this.cache.clear();
2313
+ }
2314
+ async fetchUsage(jobToken, signal) {
2315
+ const data = await openApiJsonRequest(this.fetchImpl, {
2316
+ url: getQoderUsageUrl(this.region),
2317
+ token: jobToken,
2318
+ signal,
2319
+ timeoutMs: this.timeoutMs,
2320
+ logger: this.logger,
2321
+ operation: "Usage",
2322
+ logCategory: "account.usage"
2323
+ });
2324
+ return {
2325
+ userQuota: normalizeQuota(data.userQuota),
2326
+ orgResourcePackage: normalizeQuota(data.orgResourcePackage),
2327
+ totalUsagePercentage: typeof data.totalUsagePercentage === "number" ? data.totalUsagePercentage : void 0,
2328
+ isQuotaExceeded: typeof data.isQuotaExceeded === "boolean" ? data.isQuotaExceeded : false,
2329
+ expiresAt: normalizeExpiresAt(data.expiresAt),
2330
+ raw: data
2331
+ };
2332
+ }
2333
+ async fetchPlan(jobToken, signal) {
2334
+ return normalizePlan(await openApiJsonRequest(this.fetchImpl, {
2335
+ url: getQoderUserPlanUrl(this.region),
2336
+ token: jobToken,
2337
+ signal,
2338
+ timeoutMs: this.timeoutMs,
2339
+ logger: this.logger,
2340
+ operation: "Plan",
2341
+ logCategory: "account.plan"
2342
+ }));
2343
+ }
2344
+ async safeFetchPlan(jobToken, signal) {
2345
+ try {
2346
+ return await this.fetchPlan(jobToken, signal);
2347
+ } catch (error) {
2348
+ if (signal.aborted) throw error;
2349
+ this.logger?.warn?.("[Qoder Plan] Failed to load user plan (degraded)", error instanceof Error ? error.message : error);
2350
+ return;
2351
+ }
2352
+ }
2353
+ async fetchStatus(jobToken, machineId, signal) {
2354
+ return normalizeStatus(await openApiJsonRequest(this.fetchImpl, {
2355
+ url: getQoderUserStatusUrl(this.region),
2356
+ token: jobToken,
2357
+ machineId,
2358
+ signal,
2359
+ timeoutMs: this.timeoutMs,
2360
+ logger: this.logger,
2361
+ operation: "Status",
2362
+ logCategory: "account.status"
2363
+ }));
2364
+ }
2365
+ async safeFetchStatus(jobToken, machineId, signal) {
2366
+ try {
2367
+ return await this.fetchStatus(jobToken, machineId, signal);
2368
+ } catch (error) {
2369
+ if (signal?.aborted) throw error;
2370
+ this.logger?.warn?.("[Qoder Status] Failed to load user status (degraded)", error instanceof Error ? error.message : error);
2371
+ return;
2372
+ }
2373
+ }
2374
+ };
2375
+ //#endregion
2376
+ //#region src/qoder/transport/wire/encoding.ts
2377
+ /**
2378
+ * Qoder WAF Body encoding algorithm.
2379
+ *
2380
+ * Re-arranges standard Base64 chunks and translates characters against
2381
+ * a custom alphabet to satisfy Qoder upstream WAF requirements.
2382
+ *
2383
+ * @module dsh-provider-qoder/qoder/transport/wire/encoding
2384
+ */
2385
+ const qoderCustomAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
2386
+ const qoderStdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2387
+ function qoderEncodeBody(plaintext) {
2388
+ const std = Buffer.isBuffer(plaintext) ? plaintext.toString("base64") : Buffer.from(plaintext).toString("base64");
2389
+ const n = std.length;
2390
+ const a = Math.floor(n / 3);
2391
+ const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
2392
+ let out = "";
2393
+ for (let i = 0; i < n; i++) {
2394
+ const c = rearranged[i];
2395
+ if (c === void 0) continue;
2396
+ if (c === "=") out += "$";
2397
+ else {
2398
+ const idx = qoderStdAlphabet.indexOf(c);
2399
+ if (idx >= 0) out += qoderCustomAlphabet[idx];
2400
+ else out += c;
2401
+ }
2402
+ }
2403
+ return out;
2404
+ }
2405
+ //#endregion
2406
+ //#region src/qoder/transport/wire/thinking.ts
2407
+ const thinkingTags = [{
2408
+ open: "<thinking>",
2409
+ close: "</thinking>"
2410
+ }, {
2411
+ open: "<think>",
2412
+ close: "</think>"
2413
+ }];
2414
+ const allTags = thinkingTags.flatMap((tag) => [tag.open, tag.close]);
2415
+ function trailingPrefixLength(text, candidates) {
2416
+ let best = 0;
2417
+ for (const candidate of candidates) {
2418
+ const limit = Math.min(text.length, candidate.length - 1);
2419
+ for (let length = limit; length > best; length--) if (text.endsWith(candidate.slice(0, length))) {
2420
+ best = length;
2421
+ break;
2422
+ }
2423
+ }
2424
+ return best;
2425
+ }
2426
+ function earliestTag(text, kind) {
2427
+ let found;
2428
+ for (const tag of thinkingTags) {
2429
+ const position = text.indexOf(tag[kind]);
2430
+ if (position !== -1 && (found === void 0 || position < found.position)) found = {
2431
+ position,
2432
+ tag
2433
+ };
2434
+ }
2435
+ return found;
2436
+ }
2437
+ function trimSeparator(text) {
2438
+ if (text.startsWith("\n\n")) return text.slice(2);
2439
+ if (text.startsWith("\n")) return text.slice(1);
2440
+ return text;
2441
+ }
2442
+ var NativeReasoningStripper = class {
2443
+ buffer = "";
2444
+ push(chunk) {
2445
+ this.buffer += chunk;
2446
+ let output = "";
2447
+ while (this.buffer) {
2448
+ let firstPosition = -1;
2449
+ let firstTag = "";
2450
+ for (const tag of allTags) {
2451
+ const position = this.buffer.indexOf(tag);
2452
+ if (position !== -1 && (firstPosition === -1 || position < firstPosition)) {
2453
+ firstPosition = position;
2454
+ firstTag = tag;
2455
+ }
2456
+ }
2457
+ if (firstPosition !== -1) {
2458
+ output += this.buffer.slice(0, firstPosition);
2459
+ this.buffer = this.buffer.slice(firstPosition + firstTag.length);
2460
+ continue;
2461
+ }
2462
+ const held = trailingPrefixLength(this.buffer, allTags);
2463
+ output += this.buffer.slice(0, this.buffer.length - held);
2464
+ this.buffer = this.buffer.slice(this.buffer.length - held);
2465
+ break;
2466
+ }
2467
+ return output;
2468
+ }
2469
+ finish() {
2470
+ const output = allTags.some((tag) => tag.startsWith(this.buffer)) ? "" : this.buffer;
2471
+ this.buffer = "";
2472
+ return output;
2473
+ }
2474
+ };
2475
+ var QoderThinkingParser = class {
2476
+ buffer = "";
2477
+ mode = "text";
2478
+ closingTag = thinkingTags[0].close;
2479
+ native = new NativeReasoningStripper();
2480
+ pushReasoning(chunk) {
2481
+ const text = this.native.push(chunk);
2482
+ return text ? [{
2483
+ type: "reasoning",
2484
+ text
2485
+ }] : [];
2486
+ }
2487
+ pushContent(chunk) {
2488
+ const output = [];
2489
+ const pendingReasoning = this.native.finish();
2490
+ if (pendingReasoning) output.push({
2491
+ type: "reasoning",
2492
+ text: pendingReasoning
2493
+ });
2494
+ this.buffer += chunk;
2495
+ while (this.buffer) {
2496
+ if (this.mode === "reasoning") {
2497
+ const closeAt = this.buffer.indexOf(this.closingTag);
2498
+ if (closeAt !== -1) {
2499
+ if (closeAt > 0) output.push({
2500
+ type: "reasoning",
2501
+ text: this.buffer.slice(0, closeAt)
2502
+ });
2503
+ this.buffer = trimSeparator(this.buffer.slice(closeAt + this.closingTag.length));
2504
+ this.mode = "text";
2505
+ continue;
2506
+ }
2507
+ const held = trailingPrefixLength(this.buffer, [this.closingTag]);
2508
+ const safeLength = this.buffer.length - held;
2509
+ if (safeLength > 0) output.push({
2510
+ type: "reasoning",
2511
+ text: this.buffer.slice(0, safeLength)
2512
+ });
2513
+ this.buffer = this.buffer.slice(safeLength);
2514
+ break;
2515
+ }
2516
+ const opener = earliestTag(this.buffer, "open");
2517
+ const closer = earliestTag(this.buffer, "close");
2518
+ if (opener !== void 0 && (closer === void 0 || opener.position < closer.position)) {
2519
+ if (opener.position > 0) output.push({
2520
+ type: "text",
2521
+ text: this.buffer.slice(0, opener.position)
2522
+ });
2523
+ this.buffer = this.buffer.slice(opener.position + opener.tag.open.length);
2524
+ this.closingTag = opener.tag.close;
2525
+ this.mode = "reasoning";
2526
+ continue;
2527
+ }
2528
+ if (closer !== void 0) {
2529
+ if (closer.position > 0) output.push({
2530
+ type: "text",
2531
+ text: this.buffer.slice(0, closer.position)
2532
+ });
2533
+ this.buffer = trimSeparator(this.buffer.slice(closer.position + closer.tag.close.length));
2534
+ continue;
2535
+ }
2536
+ const held = trailingPrefixLength(this.buffer, allTags);
2537
+ const safeLength = this.buffer.length - held;
2538
+ if (safeLength > 0) output.push({
2539
+ type: "text",
2540
+ text: this.buffer.slice(0, safeLength)
2541
+ });
2542
+ this.buffer = this.buffer.slice(safeLength);
2543
+ break;
2544
+ }
2545
+ return output;
2546
+ }
2547
+ finish() {
2548
+ const output = [];
2549
+ const pendingReasoning = this.native.finish();
2550
+ if (pendingReasoning) output.push({
2551
+ type: "reasoning",
2552
+ text: pendingReasoning
2553
+ });
2554
+ if (this.buffer) output.push({
2555
+ type: this.mode,
2556
+ text: this.buffer
2557
+ });
2558
+ this.buffer = "";
2559
+ return output;
2560
+ }
2561
+ };
2562
+ //#endregion
2563
+ //#region src/qoder/transport/wire/sse.ts
2564
+ /** Decode Qoder's outer SSE envelope and inner model chunks. */
2565
+ const doneMarker = "[DONE]";
2566
+ function malformed(message) {
2567
+ return new QoderLlmError(message, "MALFORMED_RESPONSE");
2568
+ }
2569
+ function parseEnvelope(rawData) {
2570
+ let value;
2571
+ try {
2572
+ value = JSON.parse(rawData);
2573
+ } catch {
2574
+ throw malformed("Malformed outer SSE JSON payload received from Qoder.");
2575
+ }
2576
+ if (value === null || typeof value !== "object") throw malformed("Malformed outer SSE envelope received from Qoder.");
2577
+ const envelope = value;
2578
+ if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
2579
+ if (envelope.statusCodeValue !== void 0 && (!Number.isInteger(envelope.statusCodeValue) || envelope.statusCodeValue < 100 || envelope.statusCodeValue > 599)) throw malformed("Qoder SSE envelope has an invalid status code.");
2580
+ if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) throw qoderHttpError(`Qoder service returned upstream error status ${envelope.statusCodeValue}.`, { status: envelope.statusCodeValue });
2581
+ if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
2582
+ return envelope;
2583
+ }
2584
+ function parseInner(body) {
2585
+ try {
2586
+ const value = JSON.parse(body);
2587
+ if (value === null || typeof value !== "object") throw new Error("not an object");
2588
+ return value;
2589
+ } catch {
2590
+ throw malformed("Malformed inner model payload received from Qoder.");
2591
+ }
2592
+ }
2593
+ function tokenUsage(innerChunk) {
2594
+ if (!innerChunk.usage) return void 0;
2595
+ const usage = innerChunk.usage;
2596
+ const promptTokens = usage.prompt_tokens ?? 0;
2597
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
2598
+ const cacheWriteTokens = usage.prompt_tokens_details?.cache_write_tokens ?? 0;
2599
+ const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens;
2600
+ return {
2601
+ inputTokens: Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens),
2602
+ outputTokens: usage.completion_tokens ?? 0,
2603
+ ...cacheReadTokens > 0 ? { cacheReadTokens } : {},
2604
+ ...cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
2605
+ ...reasoningTokens === void 0 ? {} : { reasoningTokens }
2606
+ };
2607
+ }
2608
+ function finishKind(value) {
2609
+ if (value === void 0 || value === null || value === "") return void 0;
2610
+ if (value === "stop") return "stop";
2611
+ if (value === "length") return "max-tokens";
2612
+ if (value === "tool_calls" || value === "toolUse") return "tool-calls";
2613
+ if (value === "content_filter") throw new QoderLlmError("Qoder blocked the response through its content filter.", "PROVIDER_ERROR");
2614
+ throw malformed(`Qoder returned unknown finish reason "${value}".`);
2615
+ }
2616
+ async function* parseQoderSse(stream, options = {}) {
2617
+ const reader = stream.getReader();
2618
+ const decoder = new TextDecoder();
2619
+ const thinkingParser = new QoderThinkingParser();
2620
+ const toolCalls = /* @__PURE__ */ new Map();
2621
+ let buffer = "";
2622
+ let sourceEnded = false;
2623
+ let sawDone = false;
2624
+ let nextBlockIndex = 0;
2625
+ let activeTextual;
2626
+ let pendingUsage;
2627
+ let terminalKind = "stop";
2628
+ let sawContent = false;
2629
+ const maxBufferChars = options.maxBufferChars ?? 2097152;
2630
+ const closeTextual = () => {
2631
+ if (activeTextual === void 0) return [];
2632
+ const state = activeTextual;
2633
+ activeTextual = void 0;
2634
+ return [{
2635
+ type: "block-end",
2636
+ index: state.index,
2637
+ block: state.type === "text" ? {
2638
+ type: "text",
2639
+ text: state.text
2640
+ } : {
2641
+ type: "reasoning",
2642
+ text: state.text
2643
+ }
2644
+ }];
2645
+ };
2646
+ const appendSegment = (segment) => {
2647
+ if (!segment.text) return [];
2648
+ const chunks = [];
2649
+ sawContent = true;
2650
+ if (activeTextual?.type !== segment.type) {
2651
+ chunks.push(...closeTextual());
2652
+ activeTextual = {
2653
+ type: segment.type,
2654
+ index: nextBlockIndex++,
2655
+ text: ""
2656
+ };
2657
+ chunks.push({
2658
+ type: "block-start",
2659
+ index: activeTextual.index,
2660
+ blockType: segment.type
2661
+ });
2662
+ }
2663
+ activeTextual.text += segment.text;
2664
+ chunks.push(segment.type === "text" ? {
2665
+ type: "text-delta",
2666
+ index: activeTextual.index,
2667
+ text: segment.text
2668
+ } : {
2669
+ type: "reasoning-delta",
2670
+ index: activeTextual.index,
2671
+ text: segment.text
2672
+ });
2673
+ return chunks;
2674
+ };
2675
+ const openToolCall = (state) => {
2676
+ if (!state.id || state.blockIndex !== void 0) return [];
2677
+ const chunks = closeTextual();
2678
+ state.blockIndex = nextBlockIndex++;
2679
+ sawContent = true;
2680
+ chunks.push({
2681
+ type: "block-start",
2682
+ index: state.blockIndex,
2683
+ blockType: "tool-call"
2684
+ });
2685
+ const argumentsDelta = state.arguments.slice(state.emittedArguments);
2686
+ state.emittedArguments = state.arguments.length;
2687
+ chunks.push({
2688
+ type: "tool-call-delta",
2689
+ index: state.blockIndex,
2690
+ id: ToolCallId(state.id),
2691
+ ...state.name ? { name: state.name } : {},
2692
+ argumentsDelta
2693
+ });
2694
+ return chunks;
2695
+ };
2696
+ try {
2697
+ while (!sourceEnded && !sawDone) {
2698
+ const { done, value } = await reader.read();
2699
+ if (done) {
2700
+ sourceEnded = true;
2701
+ buffer += decoder.decode();
2702
+ if (buffer.length > 0 && !buffer.endsWith("\n")) buffer += "\n";
2703
+ } else {
2704
+ options.onActivity?.();
2705
+ buffer += decoder.decode(value, { stream: true });
2706
+ if (buffer.length > maxBufferChars) throw malformed("Qoder SSE frame exceeded its size limit.");
2707
+ }
2708
+ while (!sawDone) {
2709
+ const lineEnd = buffer.indexOf("\n");
2710
+ if (lineEnd === -1) break;
2711
+ let line = buffer.substring(0, lineEnd);
2712
+ buffer = buffer.substring(lineEnd + 1);
2713
+ if (line.endsWith("\r")) line = line.slice(0, -1);
2714
+ line = line.trim();
2715
+ if (!line.startsWith("data:")) continue;
2716
+ const rawData = line.substring(5).trim();
2717
+ if (!rawData) continue;
2718
+ if (rawData === doneMarker) {
2719
+ sawDone = true;
2720
+ break;
2721
+ }
2722
+ const body = parseEnvelope(rawData).body?.trim();
2723
+ if (!body) continue;
2724
+ if (body === doneMarker) {
2725
+ sawDone = true;
2726
+ break;
2727
+ }
2728
+ const innerChunk = parseInner(body);
2729
+ pendingUsage = tokenUsage(innerChunk) ?? pendingUsage;
2730
+ for (const choice of innerChunk.choices ?? []) {
2731
+ terminalKind = finishKind(choice.finish_reason) ?? terminalKind;
2732
+ const delta = choice.delta;
2733
+ if (!delta) continue;
2734
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content) for (const segment of thinkingParser.pushReasoning(delta.reasoning_content)) for (const chunk of appendSegment(segment)) yield chunk;
2735
+ if (typeof delta.content === "string" && delta.content) for (const segment of thinkingParser.pushContent(delta.content)) for (const chunk of appendSegment(segment)) yield chunk;
2736
+ if (delta.tool_calls !== void 0) {
2737
+ if (!Array.isArray(delta.tool_calls)) throw malformed("Qoder tool-call delta is not an array.");
2738
+ for (const rawCall of delta.tool_calls) {
2739
+ if (typeof rawCall !== "object" || rawCall === null || Array.isArray(rawCall)) throw malformed("Qoder tool-call delta is not an object.");
2740
+ const upstreamIndex = rawCall.index ?? 0;
2741
+ if (!Number.isInteger(upstreamIndex) || upstreamIndex < 0) throw malformed("Qoder tool-call delta has an invalid index.");
2742
+ let state = toolCalls.get(upstreamIndex);
2743
+ if (state === void 0) {
2744
+ state = {
2745
+ id: "",
2746
+ name: "",
2747
+ arguments: "",
2748
+ emittedArguments: 0
2749
+ };
2750
+ toolCalls.set(upstreamIndex, state);
2751
+ }
2752
+ if (rawCall.id !== void 0) {
2753
+ if (rawCall.id === "" || rawCall.id === null) {} else if (typeof rawCall.id !== "string") throw malformed("Qoder tool call has an invalid id.");
2754
+ else {
2755
+ if (state.id && state.id !== rawCall.id) throw malformed("Qoder changed a streamed tool-call id.");
2756
+ state.id = rawCall.id;
2757
+ }
2758
+ }
2759
+ let hasNewName = false;
2760
+ if (rawCall.function?.name !== void 0) {
2761
+ const name = rawCall.function.name;
2762
+ if (name === "" || name === null) {} else if (typeof name !== "string") throw malformed("Qoder tool call has an invalid name.");
2763
+ else {
2764
+ if (state.name && state.name !== name) throw malformed("Qoder changed a streamed tool-call name.");
2765
+ if (state.name !== name) {
2766
+ state.name = name;
2767
+ hasNewName = true;
2768
+ }
2769
+ }
2770
+ }
2771
+ if (rawCall.function?.arguments !== void 0) {
2772
+ if (typeof rawCall.function.arguments !== "string") throw malformed("Qoder tool-call arguments delta is not a string.");
2773
+ state.arguments += rawCall.function.arguments;
2774
+ }
2775
+ const wasOpen = state.blockIndex !== void 0;
2776
+ for (const chunk of openToolCall(state)) yield chunk;
2777
+ if (wasOpen && state.blockIndex !== void 0 && state.emittedArguments < state.arguments.length) {
2778
+ const argumentsDelta = state.arguments.slice(state.emittedArguments);
2779
+ state.emittedArguments = state.arguments.length;
2780
+ yield {
2781
+ type: "tool-call-delta",
2782
+ index: state.blockIndex,
2783
+ id: ToolCallId(state.id),
2784
+ ...state.name ? { name: state.name } : {},
2785
+ argumentsDelta
2786
+ };
2787
+ } else if (wasOpen && state.blockIndex !== void 0 && hasNewName) yield {
2788
+ type: "tool-call-delta",
2789
+ index: state.blockIndex,
2790
+ id: ToolCallId(state.id),
2791
+ name: state.name,
2792
+ argumentsDelta: ""
2793
+ };
2794
+ }
2795
+ }
2796
+ }
2797
+ }
2798
+ }
2799
+ } finally {
2800
+ await reader.cancel().catch(() => void 0);
2801
+ reader.releaseLock();
2802
+ }
2803
+ if (!sawDone) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
2804
+ for (const segment of thinkingParser.finish()) for (const chunk of appendSegment(segment)) yield chunk;
2805
+ for (const chunk of closeTextual()) yield chunk;
2806
+ for (const [, state] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
2807
+ if (!state.id || !state.name) throw malformed("Qoder completed an unidentifiable tool call.");
2808
+ const normalizedArguments = state.arguments.trim() ? state.arguments : "{}";
2809
+ try {
2810
+ JSON.parse(normalizedArguments);
2811
+ } catch {
2812
+ throw malformed(`Qoder completed tool call "${state.name}" with malformed JSON arguments.`);
2813
+ }
2814
+ for (const chunk of openToolCall(state)) yield chunk;
2815
+ if (state.blockIndex === void 0) throw malformed("Qoder failed to open a completed tool call.");
2816
+ if (state.emittedArguments === 0 && normalizedArguments === "{}") yield {
2817
+ type: "tool-call-delta",
2818
+ index: state.blockIndex,
2819
+ id: ToolCallId(state.id),
2820
+ name: state.name,
2821
+ argumentsDelta: "{}"
2822
+ };
2823
+ yield {
2824
+ type: "block-end",
2825
+ index: state.blockIndex,
2826
+ block: {
2827
+ type: "tool-call",
2828
+ id: ToolCallId(state.id),
2829
+ name: state.name,
2830
+ arguments: normalizedArguments
2831
+ }
2832
+ };
2833
+ }
2834
+ if (pendingUsage) yield {
2835
+ type: "usage",
2836
+ usage: pendingUsage
2837
+ };
2838
+ if (!sawContent) {
2839
+ yield {
2840
+ type: "finish",
2841
+ reason: {
2842
+ kind: "error",
2843
+ failure: {
2844
+ message: "Model returned a completed response with no content.",
2845
+ code: EMPTY_RESPONSE_CODE
2846
+ }
2847
+ }
2848
+ };
2849
+ return;
2850
+ }
2851
+ yield {
2852
+ type: "finish",
2853
+ reason: { kind: toolCalls.size > 0 ? "tool-calls" : terminalKind }
2854
+ };
2855
+ }
2856
+ //#endregion
2857
+ //#region src/qoder/transport/chat.ts
2858
+ /** Qoder chat request encoding, timeout lifecycle, and SSE streaming. */
2859
+ function aborted$1(message) {
2860
+ return new QoderLlmError(message, "ABORTED");
2861
+ }
2862
+ async function* streamQoderChat(options, model, credentials, messages, dependencies) {
2863
+ const request = await buildQoderRequestBody(options, credentials.userID, messages, model);
2864
+ const encodedBody = qoderEncodeBody(JSON.stringify(request));
2865
+ const encodedBytes = Buffer.from(encodedBody, "utf8");
2866
+ const chatUrl = getQoderChatUrl(dependencies.region);
2867
+ const requestController = new AbortController();
2868
+ let headerTimedOut = false;
2869
+ let idleTimedOut = false;
2870
+ let idleTimer;
2871
+ let chunkCount = 0;
2872
+ let reqId;
2873
+ const startedAt = performance.now();
2874
+ let lastActivityAt = startedAt;
2875
+ const onCallerAbort = () => requestController.abort(options.signal?.reason);
2876
+ options.signal?.addEventListener("abort", onCallerAbort, { once: true });
2877
+ const headerTimer = setTimeout(() => {
2878
+ headerTimedOut = true;
2879
+ requestController.abort("response header timeout");
2880
+ }, dependencies.responseHeaderTimeoutMs);
2881
+ const resetIdleTimer = () => {
2882
+ lastActivityAt = performance.now();
2883
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
2884
+ idleTimer = setTimeout(() => {
2885
+ idleTimedOut = true;
2886
+ requestController.abort("stream idle timeout");
2887
+ }, dependencies.streamIdleTimeoutMs);
2888
+ };
2889
+ try {
2890
+ const response = await dependencies.fetch(chatUrl, {
2891
+ method: "POST",
2892
+ headers: {
2893
+ "content-type": "application/json",
2894
+ "accept": "text/event-stream",
2895
+ "cache-control": "no-cache",
2896
+ "accept-encoding": "identity",
2897
+ "x-model-key": options.model || "cmodel",
2898
+ "x-model-source": model?.source || "system",
2899
+ ...attributionHeaders(),
2900
+ ...buildAuthHeaders(encodedBytes, chatUrl, credentials)
2901
+ },
2902
+ body: encodedBytes,
2903
+ signal: requestController.signal
2904
+ });
2905
+ clearTimeout(headerTimer);
2906
+ reqId = qoderRequestId(response.headers);
2907
+ dependencies.logger?.debug?.("[Qoder Stream] Response headers received", {
2908
+ region: dependencies.region,
2909
+ status: response.status,
2910
+ durationMs: Math.round(performance.now() - startedAt),
2911
+ ...reqId === void 0 ? {} : { requestId: reqId }
2912
+ });
2913
+ resetIdleTimer();
2914
+ if (!response.ok) throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response);
2915
+ if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
2916
+ let firstChunkDurationMs;
2917
+ let tokenUsage;
2918
+ let finishReason;
2919
+ const streamStartedAt = performance.now();
2920
+ for await (const chunk of parseQoderSse(response.body, { onActivity: resetIdleTimer })) {
2921
+ chunkCount++;
2922
+ if (firstChunkDurationMs === void 0) {
2923
+ firstChunkDurationMs = Math.round(performance.now() - startedAt);
2924
+ dependencies.logger?.debug?.("[Qoder Stream] First chunk received", {
2925
+ durationMs: firstChunkDurationMs,
2926
+ ...reqId === void 0 ? {} : { requestId: reqId }
2927
+ });
2928
+ }
2929
+ if (chunk.type === "usage") tokenUsage = chunk.usage;
2930
+ if (chunk.type === "finish") finishReason = chunk.reason.kind;
2931
+ yield chunk;
2932
+ }
2933
+ dependencies.logger?.debug?.("[Qoder Stream] Stream completed", {
2934
+ durationMs: Math.round(performance.now() - startedAt),
2935
+ streamDurationMs: Math.round(performance.now() - streamStartedAt),
2936
+ chunkCount,
2937
+ ...finishReason === void 0 ? {} : { finishReason },
2938
+ ...tokenUsage === void 0 ? {} : { usage: tokenUsage },
2939
+ ...reqId === void 0 ? {} : { requestId: reqId }
2940
+ });
2941
+ } catch (error) {
2942
+ if (options.signal?.aborted) throw aborted$1("Request was aborted.");
2943
+ const elapsedMs = Math.round(performance.now() - startedAt);
2944
+ if (headerTimedOut) {
2945
+ const failure = new QoderLlmError("Qoder model request exceeded its response header timeout.", "TIMEOUT");
2946
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
2947
+ phase: "header",
2948
+ elapsedMs,
2949
+ timeoutMs: dependencies.responseHeaderTimeoutMs
2950
+ });
2951
+ throw failure;
2952
+ }
2953
+ if (idleTimedOut) {
2954
+ const failure = new QoderLlmError("Qoder model stream exceeded its idle timeout.", "TIMEOUT");
2955
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
2956
+ phase: "stream-idle",
2957
+ chunkCount,
2958
+ idleDurationMs: Math.round(performance.now() - lastActivityAt),
2959
+ elapsedMs,
2960
+ ...reqId === void 0 ? {} : { requestId: reqId }
2961
+ });
2962
+ throw failure;
2963
+ }
2964
+ if (error instanceof QoderLlmError) {
2965
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(error), {
2966
+ chunkCount,
2967
+ elapsedMs,
2968
+ ...reqId === void 0 ? {} : { requestId: reqId }
2969
+ });
2970
+ throw error;
2971
+ }
2972
+ const failure = new QoderLlmError("Qoder transport request failed.", "TRANSPORT", { cause: error });
2973
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
2974
+ chunkCount,
2975
+ elapsedMs,
2976
+ ...reqId === void 0 ? {} : { requestId: reqId },
2977
+ cause: redactLogValue(error)
2978
+ });
2979
+ throw failure;
2980
+ } finally {
2981
+ clearTimeout(headerTimer);
2982
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
2983
+ options.signal?.removeEventListener("abort", onCallerAbort);
2984
+ requestController.abort("request complete");
2985
+ }
2986
+ }
2987
+ function aborted(message) {
2988
+ return new QoderLlmError(message, "ABORTED");
2989
+ }
2990
+ var DefaultQoderTransport = class {
2991
+ region;
2992
+ resolvePat;
2993
+ fetchImpl;
2994
+ logger;
2995
+ streamIdleTimeoutMs;
2996
+ responseHeaderTimeoutMs;
2997
+ metadataTimeoutMs;
2998
+ auth;
2999
+ usage;
3000
+ modelFlights = new SingleFlight();
3001
+ attachments;
3002
+ imageUploader;
3003
+ preserveThinking;
3004
+ constructor(options) {
3005
+ this.region = options.region;
3006
+ this.resolvePat = options.resolvePat;
3007
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
3008
+ this.logger = options.logger;
3009
+ this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 3e5;
3010
+ this.responseHeaderTimeoutMs = options.responseHeaderTimeoutMs ?? 6e4;
3011
+ this.metadataTimeoutMs = options.metadataTimeoutMs;
3012
+ this.attachments = options.attachments;
3013
+ this.preserveThinking = options.preserveThinking;
3014
+ this.auth = new QoderAuthService({
3015
+ fetch: this.fetchImpl,
3016
+ logger: this.logger,
3017
+ region: this.region,
3018
+ resolveMachineId: options.resolveMachineId
3019
+ });
3020
+ this.imageUploader = new QoderImageUploader({
3021
+ fetch: this.fetchImpl,
3022
+ logger: this.logger,
3023
+ region: this.region,
3024
+ ...options.imageUploadTimeoutMs === void 0 ? {} : { timeoutMs: options.imageUploadTimeoutMs },
3025
+ ...options.imageUrlCacheTtlMs === void 0 ? {} : { cacheTtlMs: options.imageUrlCacheTtlMs },
3026
+ refreshCredentials: async (signal) => {
3027
+ const pat = await this.requirePat(signal);
3028
+ this.auth.clear(pat);
3029
+ return this.auth.getCredentials(pat, signal);
3030
+ }
3031
+ });
3032
+ this.usage = new QoderUsageReader({
3033
+ authService: this.auth,
3034
+ fetch: this.fetchImpl,
3035
+ logger: this.logger,
3036
+ region: this.region,
3037
+ timeoutMs: this.metadataTimeoutMs
3038
+ });
3039
+ }
3040
+ stream(options, model) {
3041
+ return this.generate(options, model);
3042
+ }
3043
+ async discoverModels(signal) {
3044
+ const pat = await this.requirePat(signal);
3045
+ const key = opaqueCredentialKey(pat);
3046
+ return this.modelFlights.run(key, signal, async (sharedSignal) => {
3047
+ const credentials = await this.auth.getCredentials(pat, sharedSignal);
3048
+ return retryMetadataRead(sharedSignal, () => fetchQoderModels(credentials, {
3049
+ fetch: this.fetchImpl,
3050
+ signal: sharedSignal,
3051
+ logger: this.logger,
3052
+ region: this.region,
3053
+ timeoutMs: this.metadataTimeoutMs
3054
+ }));
3055
+ }, () => aborted("Qoder model discovery was aborted."));
3056
+ }
3057
+ async readAccount(options) {
3058
+ const pat = await this.requirePat(options?.signal);
3059
+ return this.usage.readAccount(pat, {
3060
+ force: options?.force,
3061
+ signal: options?.signal
3062
+ });
3063
+ }
3064
+ async requirePat(signal) {
3065
+ if (signal?.aborted) throw aborted("Qoder request was aborted.");
3066
+ const pat = (await this.resolvePat()).trim();
3067
+ if (!pat) throw new QoderLlmError("Qoder Personal Access Token is missing. Configure Qoder in the Qoder settings page.", "MISSING_CREDENTIAL");
3068
+ if (signal?.aborted) throw aborted("Qoder request was aborted.");
3069
+ return pat;
3070
+ }
3071
+ async *generate(options, model) {
3072
+ if (options.signal?.aborted) throw aborted("Request was aborted prior to generation.");
3073
+ validateQoderRequestShape(options, model);
3074
+ const pat = await this.requirePat(options.signal);
3075
+ const credentials = await this.auth.getCredentials(pat, options.signal);
3076
+ const messages = await translateQoderMessages(options, this.attachments, {
3077
+ uploader: this.imageUploader,
3078
+ credentials,
3079
+ preserveThinking: this.preserveThinking
3080
+ });
3081
+ yield* streamQoderChat(options, model, await this.auth.getCredentials(pat, options.signal), messages, {
3082
+ fetch: this.fetchImpl,
3083
+ logger: this.logger,
3084
+ region: this.region,
3085
+ responseHeaderTimeoutMs: this.responseHeaderTimeoutMs,
3086
+ streamIdleTimeoutMs: this.streamIdleTimeoutMs
3087
+ });
3088
+ }
3089
+ };
3090
+ //#endregion
3091
+ //#region src/qoder/transport/index.ts
3092
+ function createQoderTransport(options) {
3093
+ return new DefaultQoderTransport(options);
3094
+ }
3095
+ //#endregion
3096
+ //#region src/upstream.ts
3097
+ /**
3098
+ * Qoder upstream client — the loopback shim's OpenAI-shaped surface, carried
3099
+ * over to the Qoder transport.
3100
+ *
3101
+ * The plugin's browser card, the CLI, and DSH's pi-ai adapter all reach Qoder
3102
+ * through this one module. The wire reality changed underneath the WorkBuddy
3103
+ * edition it replaces: there is no HTTP endpoint that accepts an OpenAI JSON
3104
+ * body any more. Instead this module
3105
+ *
3106
+ * - **translates** the narrow OpenAI chat-completions request the shim forwards
3107
+ * (model, messages, tools, `max_tokens`, `temperature`, `stop`,
3108
+ * `reasoning_effort`) into dsh-llm's `GenerateOptions`, and
3109
+ * - **re-serializes** the transport's `StreamChunk` vocabulary back into
3110
+ * `chat.completion.chunk` SSE frames, so the adapter side needs nothing
3111
+ * Qoder-specific, and
3112
+ * - maps catalog discovery (`transport.discoverModels`) into the plugin's
3113
+ * model rows, and the account read (`transport.readAccount`) into the
3114
+ * card's credit shape.
3115
+ *
3116
+ * Failure classification follows the transport's error taxonomy
3117
+ * (`QoderLlmError.failure.code` plus the HTTP status it carried); the
3118
+ * WorkBuddy-era session/credit marker lists are gone with the upstream they
3119
+ * described.
3120
+ *
3121
+ * @module dsh-qoder-connect/upstream
3122
+ */
3123
+ /** HTTP status the shim answers each failure kind with. */
3124
+ const KIND_STATUS = {
3125
+ missing_credential: 401,
3126
+ auth: 401,
3127
+ soft_rate: 429,
3128
+ quota_exceeded: 402,
3129
+ server: 502,
3130
+ client: 400
3131
+ };
3132
+ /**
3133
+ * Normalize a billing rate label for display.
3134
+ *
3135
+ * Qoder reports a `priceFactor` number; the host spells it `x<n>` (and the
3136
+ * WorkBuddy-era saved files sometimes carried a trailing " credits"). The card
3137
+ * wants one shape: a trimmed multiplier without the suffix.
3138
+ */
3139
+ function normalizeCredits(credits) {
3140
+ if (credits === void 0) return void 0;
3141
+ const trimmed = credits.trim().replace(/\s+credits?$/iu, "");
3142
+ return trimmed === "" ? void 0 : trimmed;
3143
+ }
3144
+ /**
3145
+ * Classify a raw HTTP upstream failure from status and a body excerpt.
3146
+ *
3147
+ * Status first, then phrases: a throttling body often also says "quota
3148
+ * exceeded", and reading that as an exhausted balance parks a healthy account
3149
+ * until the next billing day instead of retrying shortly.
3150
+ */
3151
+ function classifyUpstreamError(status, body) {
3152
+ const lower = body.toLowerCase();
3153
+ if (status === 402) return "quota_exceeded";
3154
+ if (status === 429) return "soft_rate";
3155
+ if (status === 401 || status === 403) return "auth";
3156
+ for (const marker of [
3157
+ "quota exceeded",
3158
+ "insufficient quota",
3159
+ "quota_exhausted",
3160
+ "exhausted"
3161
+ ]) if (lower.includes(marker)) return "quota_exceeded";
3162
+ for (const marker of [
3163
+ "rate limit",
3164
+ "too many requests",
3165
+ "throttl"
3166
+ ]) if (lower.includes(marker)) return "soft_rate";
3167
+ for (const marker of [
3168
+ "invalid token",
3169
+ "invalid pat",
3170
+ "unauthorized",
3171
+ "forbidden",
3172
+ "not authenticated"
3173
+ ]) if (lower.includes(marker)) return "auth";
3174
+ if (status === 0 || status >= 500) return "server";
3175
+ return "client";
3176
+ }
3177
+ /**
3178
+ * Map one structured transport failure onto the shim's error kind.
3179
+ *
3180
+ * The transport's taxonomy (`src/qoder/errors.ts`) codes an HTTP 402 as
3181
+ * `INVALID_REQUEST`, so the status gets the last say for the quota and auth
3182
+ * families before the code-based defaults apply.
3183
+ */
3184
+ function kindFromQoderFailure(failure) {
3185
+ if (failure.code === "MISSING_CREDENTIAL") return "missing_credential";
3186
+ if (failure.code === "AUTH" || failure.status === 401 || failure.status === 403) return "auth";
3187
+ if (failure.code === "RATE_LIMIT" || failure.status === 429) return "soft_rate";
3188
+ if (failure.code === "QUOTA" || failure.status === 402) return "quota_exceeded";
3189
+ if (failure.code.startsWith("INVALID_") || failure.code.startsWith("UNSUPPORTED_") || failure.code === "ATTACHMENT") return "client";
3190
+ return "server";
3191
+ }
3192
+ /** Whether a Personal Access Token works against one region's endpoints. */
3193
+ async function validateApiKey(pat, region, options = {}) {
3194
+ const trimmed = pat.trim();
3195
+ if (trimmed === "") return false;
3196
+ try {
3197
+ await createQoderTransport({
3198
+ region,
3199
+ resolvePat: async () => trimmed,
3200
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch },
3201
+ metadataTimeoutMs: options.timeoutMs ?? 15e3
3202
+ }).discoverModels(AbortSignal.timeout(options.timeoutMs ?? 15e3));
3203
+ return true;
3204
+ } catch {
3205
+ return false;
3206
+ }
3207
+ }
3208
+ /** A request the OpenAI layer itself could not accept; the shim answers 400. */
3209
+ var UpstreamRequestError = class extends Error {};
3210
+ /** One `data:` frame of a `chat.completion.chunk` SSE body. */
3211
+ function sseFrame(payload) {
3212
+ return `data: ${JSON.stringify(payload)}\n\n`;
3213
+ }
3214
+ const SSE_DONE = "data: [DONE]\n\n";
3215
+ /** The media types the attachment service normalizes; anything else is refused. */
3216
+ const IMAGE_MEDIA_TYPES = [
3217
+ "image/png",
3218
+ "image/jpeg",
3219
+ "image/webp",
3220
+ "image/gif"
3221
+ ];
3222
+ function isRecord(value) {
3223
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3224
+ }
3225
+ function requiredString(value, what) {
3226
+ if (typeof value !== "string" || value.trim() === "") throw new UpstreamRequestError(`${what} must be a non-empty string`);
3227
+ return value;
3228
+ }
3229
+ /** Decode one `data:image/...;base64,` URL into typed bytes. */
3230
+ function decodeDataImage(url) {
3231
+ const match = /^data:([a-z]+\/[a-z0-9.+-]+)(?:;[a-z0-9;=+.-]*)?,(.*)$/iu.exec(url);
3232
+ if (match === null) throw new UpstreamRequestError("image_url data URL is malformed");
3233
+ const mediaType = match[1].toLowerCase();
3234
+ if (!IMAGE_MEDIA_TYPES.includes(mediaType)) throw new UpstreamRequestError(`image media type "${mediaType}" is not supported (png, jpeg, webp, gif)`);
3235
+ let data;
3236
+ try {
3237
+ data = new Uint8Array(Buffer.from(match[2] ?? "", "base64"));
3238
+ } catch {
3239
+ throw new UpstreamRequestError("image_url base64 payload could not be decoded");
3240
+ }
3241
+ if (data.byteLength === 0) throw new UpstreamRequestError("image_url payload is empty");
3242
+ return {
3243
+ mediaType,
3244
+ data
3245
+ };
3246
+ }
3247
+ /**
3248
+ * Parse one OpenAI content value (string or parts array) into text.
3249
+ *
3250
+ * With `imagesAs`, image parts are offered for commit — and a request that
3251
+ * carries them without any commit path fails as a client error, because the
3252
+ * Qoder wire can only ever transport a durable attachment reference.
3253
+ */
3254
+ async function openAiContentBlocks(content, role, imagesAs) {
3255
+ const blocks = [];
3256
+ const parts = typeof content === "string" || content === null || content === void 0 ? typeof content === "string" && content !== "" ? [{
3257
+ type: "text",
3258
+ text: content
3259
+ }] : [] : Array.isArray(content) ? content : (() => {
3260
+ throw new UpstreamRequestError(`${role} message content must be a string or a parts array`);
3261
+ })();
3262
+ for (const raw of parts) {
3263
+ if (!isRecord(raw) || typeof raw["type"] !== "string") throw new UpstreamRequestError(`${role} message content part is malformed`);
3264
+ switch (raw["type"]) {
3265
+ case "text":
3266
+ blocks.push({
3267
+ type: "text",
3268
+ text: typeof raw["text"] === "string" ? raw["text"] : ""
3269
+ });
3270
+ break;
3271
+ case "image_url": {
3272
+ if (role !== "user") throw new UpstreamRequestError("image parts are valid only in user messages");
3273
+ const imageUrl = raw["image_url"];
3274
+ const url = isRecord(imageUrl) && typeof imageUrl["url"] === "string" ? imageUrl["url"] : "";
3275
+ if (!url.toLowerCase().startsWith("data:")) throw new UpstreamRequestError("image_url must be an inline data: URL; remote image URLs are not fetched");
3276
+ if (imagesAs === void 0) throw new UpstreamRequestError("image input requires the DSH attachment service");
3277
+ const decoded = decodeDataImage(url);
3278
+ blocks.push(await imagesAs.commit(decoded));
3279
+ break;
3280
+ }
3281
+ default: throw new UpstreamRequestError(`unsupported content part type "${raw["type"]}"`);
3282
+ }
3283
+ }
3284
+ return blocks;
3285
+ }
3286
+ /**
3287
+ * Translate one OpenAI chat-completions body into a `GenerateOptions` request.
3288
+ *
3289
+ * The layer is deliberately narrow: it accepts exactly what pi-ai's
3290
+ * `openai-completions` API sends, and rejects anything else with an
3291
+ * attributable 400 rather than silently dropping a caller's intent. System
3292
+ * prompts ride `options.system` (the transport's own slot), not a leading
3293
+ * message; tool results become dedicated user-role messages the Qoder
3294
+ * serializer accepts.
3295
+ */
3296
+ async function toGenerateOptions(bodyJson, context) {
3297
+ let parsed;
3298
+ try {
3299
+ parsed = JSON.parse(bodyJson);
3300
+ } catch {
3301
+ throw new UpstreamRequestError("request body is not valid JSON");
3302
+ }
3303
+ if (!isRecord(parsed)) throw new UpstreamRequestError("request body must be a JSON object");
3304
+ const model = requiredString(parsed["model"], "model");
3305
+ const rawMessages = parsed["messages"];
3306
+ if (!Array.isArray(rawMessages) || rawMessages.length === 0) throw new UpstreamRequestError("messages must be a non-empty array");
3307
+ const systemParts = [];
3308
+ const messages = [];
3309
+ for (const raw of rawMessages) {
3310
+ if (!isRecord(raw)) throw new UpstreamRequestError("each message must be an object");
3311
+ const role = raw["role"];
3312
+ switch (role) {
3313
+ case "system":
3314
+ case "developer":
3315
+ if (typeof raw["content"] !== "string") throw new UpstreamRequestError("system message content must be a string");
3316
+ if (raw["content"] !== "") systemParts.push(raw["content"]);
3317
+ break;
3318
+ case "user": {
3319
+ const content = await openAiContentBlocks(raw["content"], "user", context.attachments === void 0 ? void 0 : { commit: async (part) => ({
3320
+ type: "image",
3321
+ attachment: await context.attachments.saveImage({
3322
+ data: part.data,
3323
+ mediaType: part.mediaType
3324
+ })
3325
+ }) });
3326
+ messages.push(createUserMessage({
3327
+ content,
3328
+ source: { kind: "user" }
3329
+ }));
3330
+ break;
3331
+ }
3332
+ case "assistant": {
3333
+ const content = await openAiContentBlocks(raw["content"], "assistant", void 0);
3334
+ const rawCalls = raw["tool_calls"];
3335
+ if (Array.isArray(rawCalls)) for (const rawCall of rawCalls) {
3336
+ if (!isRecord(rawCall)) throw new UpstreamRequestError("each tool call must be an object");
3337
+ const fn = isRecord(rawCall["function"]) ? rawCall["function"] : rawCall;
3338
+ content.push({
3339
+ type: "tool-call",
3340
+ id: ToolCallId(requiredString(rawCall["id"], "tool call id")),
3341
+ name: requiredString(fn["name"], "tool call function name"),
3342
+ arguments: typeof fn["arguments"] === "string" ? fn["arguments"] : ""
3343
+ });
3344
+ }
3345
+ if (content.length === 0) break;
3346
+ messages.push(createAssistantMessage({
3347
+ content,
3348
+ source: {
3349
+ provider: context.providerId,
3350
+ model
3351
+ }
3352
+ }));
3353
+ break;
3354
+ }
3355
+ case "tool": {
3356
+ const callId = requiredString(raw["tool_call_id"], "tool_call_id");
3357
+ const content = raw["content"];
3358
+ let text;
3359
+ if (typeof content === "string") text = content;
3360
+ else if (Array.isArray(content)) {
3361
+ text = content.filter((part) => isRecord(part) && part["type"] === "text").map((part) => typeof part["text"] === "string" ? part["text"] : "").join("");
3362
+ if (content.some((part) => isRecord(part) && part["type"] !== "text")) throw new UpstreamRequestError("tool result content is limited to text");
3363
+ } else throw new UpstreamRequestError("tool message content must be a string or a text-parts array");
3364
+ messages.push(createToolResultMessage({
3365
+ callId: ToolCallId(callId),
3366
+ content: [{
3367
+ type: "text",
3368
+ text
3369
+ }],
3370
+ isError: false
3371
+ }));
3372
+ break;
3373
+ }
3374
+ default: throw new UpstreamRequestError(`unsupported message role "${String(role)}"`);
3375
+ }
3376
+ }
3377
+ if (messages.length === 0) throw new UpstreamRequestError("the request carries no model-facing message");
3378
+ const tools = Array.isArray(parsed["tools"]) ? parsed["tools"].map((raw) => {
3379
+ if (!isRecord(raw)) throw new UpstreamRequestError("each tool must be an object");
3380
+ const fn = isRecord(raw["function"]) ? raw["function"] : raw;
3381
+ if (!isRecord(fn)) throw new UpstreamRequestError("each tool must carry a function object");
3382
+ return {
3383
+ name: requiredString(fn["name"], "tool name"),
3384
+ description: typeof fn["description"] === "string" ? fn["description"] : "",
3385
+ parameters: isRecord(fn["parameters"]) ? fn["parameters"] : {}
3386
+ };
3387
+ }) : void 0;
3388
+ const effort = parsed["reasoning_effort"];
3389
+ const stop = parsed["stop"];
3390
+ const temperature = parsed["temperature"];
3391
+ const maxTokens = parsed["max_tokens"];
3392
+ return {
3393
+ provider: context.providerId,
3394
+ model,
3395
+ messages,
3396
+ ...systemParts.length > 0 ? { system: systemParts.join("\n") } : {},
3397
+ ...tools === void 0 || tools.length === 0 ? {} : { tools },
3398
+ ...typeof temperature === "number" && Number.isFinite(temperature) ? { temperature } : {},
3399
+ ...typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens: Math.floor(maxTokens) } : {},
3400
+ ...typeof effort === "string" && effort.trim() !== "" ? { reasoningEffort: ReasoningEffortId(effort.trim()) } : {},
3401
+ ...Array.isArray(stop) ? { stop: stop.filter((entry) => typeof entry === "string") } : typeof stop === "string" ? { stop: [stop] } : {},
3402
+ ...context.signal === void 0 ? {} : { signal: context.signal }
3403
+ };
3404
+ }
3405
+ /**
3406
+ * Re-serialize one `StreamChunk` sequence into OpenAI chunk frames.
3407
+ *
3408
+ * One assistant choice, deltas keyed by a per-block tool-call index that
3409
+ * restarts at zero per block like OpenAI's own, reasoning streamed as
3410
+ * `delta.reasoning_content` (the compatible extension other gateways use),
3411
+ * usage as a choices-less frame before the terminal finish, and `data:
3412
+ * [DONE]` last. A failed or aborted finish mid-stream becomes an error JSON
3413
+ * frame and then `[DONE]`: the HTTP status is already sent and cannot carry
3414
+ * the failure.
3415
+ */
3416
+ var ChunkEncoder = class {
3417
+ id;
3418
+ created;
3419
+ model;
3420
+ started = false;
3421
+ terminal = false;
3422
+ toolIndexByBlock = /* @__PURE__ */ new Map();
3423
+ announcedTools = /* @__PURE__ */ new Set();
3424
+ nextToolIndex = 0;
3425
+ constructor(model) {
3426
+ this.id = `chatcmpl-${randomBytes(12).toString("hex")}`;
3427
+ this.created = Math.floor(Date.now() / 1e3);
3428
+ this.model = model;
3429
+ }
3430
+ frame(extra) {
3431
+ return sseFrame({
3432
+ id: this.id,
3433
+ object: "chat.completion.chunk",
3434
+ created: this.created,
3435
+ model: this.model,
3436
+ ...extra
3437
+ });
3438
+ }
3439
+ /** The role frame opens every stream, once. */
3440
+ start() {
3441
+ if (this.started) return [];
3442
+ this.started = true;
3443
+ return [this.frame({ choices: [{
3444
+ index: 0,
3445
+ delta: { role: "assistant" },
3446
+ finish_reason: null
3447
+ }] })];
3448
+ }
3449
+ /** Translate one transport chunk into zero or more SSE frames. */
3450
+ frames(chunk) {
3451
+ switch (chunk.type) {
3452
+ case "block-start":
3453
+ case "block-end": return [];
3454
+ case "text-delta": return [this.frame({ choices: [{
3455
+ index: 0,
3456
+ delta: { content: chunk.text },
3457
+ finish_reason: null
3458
+ }] })];
3459
+ case "reasoning-delta": return [this.frame({ choices: [{
3460
+ index: 0,
3461
+ delta: { reasoning_content: chunk.text },
3462
+ finish_reason: null
3463
+ }] })];
3464
+ case "tool-call-delta": {
3465
+ let toolIndex = this.toolIndexByBlock.get(chunk.index);
3466
+ if (toolIndex === void 0) {
3467
+ toolIndex = this.nextToolIndex++;
3468
+ this.toolIndexByBlock.set(chunk.index, toolIndex);
3469
+ }
3470
+ const first = !this.announcedTools.has(chunk.index);
3471
+ if (first) this.announcedTools.add(chunk.index);
3472
+ const call = {
3473
+ index: toolIndex,
3474
+ ...first ? {
3475
+ id: String(chunk.id),
3476
+ type: "function",
3477
+ function: {
3478
+ name: chunk.name ?? "",
3479
+ arguments: chunk.argumentsDelta
3480
+ }
3481
+ } : { function: { arguments: chunk.argumentsDelta } }
3482
+ };
3483
+ return [this.frame({ choices: [{
3484
+ index: 0,
3485
+ delta: { tool_calls: [call] },
3486
+ finish_reason: null
3487
+ }] })];
3488
+ }
3489
+ case "usage": {
3490
+ const prompt = chunk.usage.inputTokens + (chunk.usage.cacheReadTokens ?? 0) + (chunk.usage.cacheWriteTokens ?? 0);
3491
+ const completion = chunk.usage.outputTokens;
3492
+ return [this.frame({
3493
+ choices: [],
3494
+ usage: {
3495
+ prompt_tokens: prompt,
3496
+ completion_tokens: completion,
3497
+ total_tokens: chunk.usage.totalTokens ?? prompt + completion
3498
+ }
3499
+ })];
3500
+ }
3501
+ case "finish": {
3502
+ this.terminal = true;
3503
+ const reason = chunk.reason;
3504
+ if (reason.kind === "error" || reason.kind === "aborted") return [sseFrame({ error: {
3505
+ message: reason.failure.message,
3506
+ type: "server_error",
3507
+ code: reason.failure.code
3508
+ } }), SSE_DONE];
3509
+ const finishReason = reason.kind === "max-tokens" ? "length" : reason.kind === "tool-calls" ? "tool_calls" : "stop";
3510
+ return [this.frame({ choices: [{
3511
+ index: 0,
3512
+ delta: {},
3513
+ finish_reason: finishReason
3514
+ }] }), SSE_DONE];
3515
+ }
3516
+ }
3517
+ }
3518
+ /** Fallback finish when the chunk source ended without a finish chunk. */
3519
+ finishFallback() {
3520
+ if (this.terminal) return [];
3521
+ this.terminal = true;
3522
+ return [this.frame({ choices: [{
3523
+ index: 0,
3524
+ delta: {},
3525
+ finish_reason: "stop"
3526
+ }] }), SSE_DONE];
3527
+ }
3528
+ /** A thrown mid-stream failure, phrased as one error frame and the end. */
3529
+ abortFrames(message, code) {
3530
+ this.terminal = true;
3531
+ return [sseFrame({ error: {
3532
+ message,
3533
+ type: "server_error",
3534
+ code
3535
+ } }), SSE_DONE];
3536
+ }
3537
+ };
3538
+ /** Turn any thrown failure into the shim's classified pre-stream result. */
3539
+ function failureResult(error) {
3540
+ if (error instanceof UpstreamRequestError) return {
3541
+ ok: false,
3542
+ status: 400,
3543
+ kind: "client",
3544
+ message: error.message
3545
+ };
3546
+ if (error instanceof LlmError) {
3547
+ const kind = kindFromQoderFailure(error.failure);
3548
+ return {
3549
+ ok: false,
3550
+ status: typeof error.failure.status === "number" && error.failure.status >= 100 && error.failure.status <= 599 ? error.failure.status : KIND_STATUS[kind],
3551
+ kind,
3552
+ message: error.failure.message
3553
+ };
3554
+ }
3555
+ return {
3556
+ ok: false,
3557
+ status: 502,
3558
+ kind: "server",
3559
+ message: String(error)
3560
+ };
3561
+ }
3562
+ var QoderUpstreamClient = class {
3563
+ region;
3564
+ providerId;
3565
+ getPat;
3566
+ transport;
3567
+ attachments;
3568
+ externalCatalog;
3569
+ /** The raw discovery rows of the last successful `fetchModels`, by id. */
3570
+ discovered = /* @__PURE__ */ new Map();
3571
+ /** Provenance of the last successful catalog fetch, for the status card. */
3572
+ lastCatalog;
3573
+ constructor(options) {
3574
+ this.region = options.region;
3575
+ this.providerId = options.providerId;
3576
+ this.getPat = options.getPat;
3577
+ this.transport = options.transport;
3578
+ this.attachments = options.attachments;
3579
+ this.externalCatalog = options.catalogProvider;
3580
+ }
3581
+ /** The region this client's transport serves. */
3582
+ get clientRegion() {
3583
+ return this.region;
3584
+ }
3585
+ /**
3586
+ * The catalog row for one model id: this client's own discovery answers
3587
+ * first (it is the same data the upstream catalog call produced), and an
3588
+ * externally supplied provider — the plugin's fallback roster, say — fills
3589
+ * ids the discovery never listed.
3590
+ */
3591
+ catalogModelFor(id) {
3592
+ return this.discovered.get(id) ?? this.externalCatalog?.(id);
3593
+ }
3594
+ /**
3595
+ * Discover this variant's models and translate them into plugin rows.
3596
+ *
3597
+ * The raw rows stay addressable via {@link catalogModelFor}: a chat request
3598
+ * for a discovered model must hand the transport its row, because effort
3599
+ * support, image capability, and the output cap are all validated against
3600
+ * it. The last successful fetch's provenance lands in {@link lastCatalog}
3601
+ * for the status card.
3602
+ */
3603
+ async fetchModels(signal) {
3604
+ const models = await this.transport.discoverModels(signal);
3605
+ this.discovered.clear();
3606
+ for (const model of models) this.discovered.set(model.id, model);
3607
+ this.lastCatalog = {
3608
+ fetchedAtMs: Date.now(),
3609
+ source: `${this.providerId}:discovery`
3610
+ };
3611
+ return models.map(modelInfoOf);
3612
+ }
3613
+ /**
3614
+ * The subscriber's quota answer, mapped onto the card's credit shape.
3615
+ *
3616
+ * Qoder reports one personal quota and an optional organization resource
3617
+ * package; each becomes a package row the card already knows how to draw.
3618
+ * An unbounded personal allowance (`total === 0`, not exceeded) is the
3619
+ * account's "unlimited" state.
3620
+ */
3621
+ async fetchCredits(signal) {
3622
+ const usage = (await this.transport.readAccount({
3623
+ force: true,
3624
+ ...signal === void 0 ? {} : { signal }
3625
+ })).usage ?? {};
3626
+ const accounts = [];
3627
+ const personal = usage.userQuota;
3628
+ if (personal !== void 0) accounts.push({
3629
+ packageName: "个人额度",
3630
+ remain: personal.remaining,
3631
+ size: personal.total,
3632
+ ...usage.expiresAt === void 0 ? {} : { packageEndTime: usage.expiresAt }
3633
+ });
3634
+ const org = usage.orgResourcePackage;
3635
+ if (org !== void 0) accounts.push({
3636
+ packageName: "组织资源包",
3637
+ remain: org.remaining,
3638
+ size: org.total,
3639
+ ...usage.expiresAt === void 0 ? {} : { packageEndTime: usage.expiresAt }
3640
+ });
3641
+ const unlimited = usage.isQuotaExceeded === false && personal !== void 0 && personal.total === 0;
3642
+ return {
3643
+ total: usage.totalUsagePercentage ?? personal?.percentage ?? 0,
3644
+ totalSize: accounts.reduce((sum, entry) => sum + entry.size, 0),
3645
+ accounts,
3646
+ ...unlimited ? { unlimited: true } : {},
3647
+ ...usage.expiresAt === void 0 ? {} : { cycleResetTime: usage.expiresAt }
3648
+ };
3649
+ }
3650
+ /**
3651
+ * Stream one translated chat-completions request.
3652
+ *
3653
+ * The first transport chunk is pulled before the response is constructed:
3654
+ * everything the transport can reject without touching the network — a
3655
+ * missing or empty credential, an effort the model does not advertise, an
3656
+ * image on a text-only model — must still be able to answer as a real HTTP
3657
+ * status, not as a 200 whose first frame is an error. Once streaming has
3658
+ * begun, failures degrade to the error-frame-then-`[DONE]` form the
3659
+ * {@link ChunkEncoder} documents.
3660
+ */
3661
+ async chatStream(bodyJson, signal) {
3662
+ let options;
3663
+ try {
3664
+ options = await toGenerateOptions(bodyJson, {
3665
+ providerId: this.providerId,
3666
+ attachments: this.attachments,
3667
+ ...signal === void 0 ? {} : { signal }
3668
+ });
3669
+ } catch (error) {
3670
+ return failureResult(error);
3671
+ }
3672
+ try {
3673
+ await this.getPat();
3674
+ } catch {
3675
+ return {
3676
+ ok: false,
3677
+ status: KIND_STATUS.missing_credential,
3678
+ kind: "missing_credential",
3679
+ message: "no Qoder Personal Access Token is configured"
3680
+ };
3681
+ }
3682
+ const encoder = new ChunkEncoder(options.model);
3683
+ let iterator;
3684
+ try {
3685
+ iterator = this.transport.stream(options, this.catalogModelFor(options.model))[Symbol.asyncIterator]();
3686
+ } catch (error) {
3687
+ return failureResult(error);
3688
+ }
3689
+ try {
3690
+ const first = await iterator.next();
3691
+ if (!first.done) {
3692
+ const earlyFailure = first.value.type === "finish" && (first.value.reason.kind === "error" || first.value.reason.kind === "aborted") ? first.value.reason.failure : void 0;
3693
+ if (earlyFailure !== void 0) {
3694
+ await iterator.return?.();
3695
+ const kind = kindFromQoderFailure(earlyFailure);
3696
+ return {
3697
+ ok: false,
3698
+ status: earlyFailure.status ?? KIND_STATUS[kind],
3699
+ kind,
3700
+ message: earlyFailure.message
3701
+ };
3702
+ }
3703
+ }
3704
+ const stream = this.bodyStream(iterator, first, encoder);
3705
+ return {
3706
+ ok: true,
3707
+ response: new Response(stream, {
3708
+ status: 200,
3709
+ headers: {
3710
+ "Content-Type": "text/event-stream; charset=utf-8",
3711
+ "Cache-Control": "no-cache",
3712
+ "Connection": "keep-alive"
3713
+ }
3714
+ })
3715
+ };
3716
+ } catch (error) {
3717
+ await iterator.return?.();
3718
+ return failureResult(error);
3719
+ }
3720
+ }
3721
+ /** Drive the SSE body from an already-started chunk iterator. */
3722
+ bodyStream(iterator, first, encoder) {
3723
+ const encoderRef = encoder;
3724
+ const toBytes = (() => {
3725
+ const text = new TextEncoder();
3726
+ return (frame) => text.encode(frame);
3727
+ })();
3728
+ let queue = [];
3729
+ let pending = first;
3730
+ return new ReadableStream({
3731
+ pull: async (controller) => {
3732
+ try {
3733
+ while (queue.length === 0 && !encoderRef.terminal) {
3734
+ if (pending === void 0) pending = await iterator.next();
3735
+ const next = pending;
3736
+ pending = void 0;
3737
+ if (next.done) {
3738
+ queue.push(...encoderRef.finishFallback());
3739
+ break;
3740
+ }
3741
+ queue.push(...encoderRef.start(), ...encoderRef.frames(next.value));
3742
+ }
3743
+ } catch (error) {
3744
+ const message = error instanceof LlmError ? error.failure.message : String(error);
3745
+ const code = error instanceof LlmError ? error.failure.code : "TRANSPORT";
3746
+ queue = encoderRef.abortFrames(message, code);
3747
+ }
3748
+ if (queue.length > 0) {
3749
+ controller.enqueue(toBytes(queue.shift()));
3750
+ if (encoderRef.terminal && queue.length === 0) controller.close();
3751
+ return;
3752
+ }
3753
+ controller.close();
3754
+ },
3755
+ cancel: async () => {
3756
+ await iterator.return?.();
3757
+ }
3758
+ });
3759
+ }
3760
+ /**
3761
+ * One minimal reasoning-effort request, as the probe sweep needs it.
3762
+ *
3763
+ * The transport validates `reasoningEffort` against the model's advertised
3764
+ * efforts **locally** before any network call: a rejection therefore reads
3765
+ * as an attributable 400 with code `UNSUPPORTED_REASONING_EFFORT`, and an
3766
+ * accepted value costs exactly one streamed request, which this method ends
3767
+ * after the first chunk arrives. A probe on this upstream measures the
3768
+ * discovery catalog as much as the endpoint behind it — see the module docs
3769
+ * in `probe.ts`.
3770
+ */
3771
+ async probeEffort(model, effort, signal) {
3772
+ try {
3773
+ await this.getPat();
3774
+ } catch {
3775
+ return {
3776
+ status: 401,
3777
+ streamed: false,
3778
+ errorCode: "MISSING_CREDENTIAL",
3779
+ detail: "no Personal Access Token configured"
3780
+ };
3781
+ }
3782
+ const options = {
3783
+ provider: this.providerId,
3784
+ model,
3785
+ messages: [createUserMessage({
3786
+ content: [{
3787
+ type: "text",
3788
+ text: PROBE_PROMPT
3789
+ }],
3790
+ source: { kind: "user" }
3791
+ })],
3792
+ maxTokens: 1,
3793
+ ...effort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(effort) },
3794
+ signal
3795
+ };
3796
+ try {
3797
+ const iterator = this.transport.stream(options, this.catalogModelFor(model))[Symbol.asyncIterator]();
3798
+ try {
3799
+ const first = await iterator.next();
3800
+ return {
3801
+ status: first.done ? 502 : 200,
3802
+ streamed: !first.done
3803
+ };
3804
+ } finally {
3805
+ await iterator.return?.();
3806
+ }
3807
+ } catch (error) {
3808
+ if (error instanceof LlmError) return {
3809
+ status: error.failure.status ?? defaultStatusForProbe(error.failure.code),
3810
+ streamed: false,
3811
+ errorCode: error.failure.code,
3812
+ detail: error.failure.message.slice(0, 300)
3813
+ };
3814
+ return {
3815
+ status: 0,
3816
+ streamed: false,
3817
+ detail: `transport error: ${String(error)}`
3818
+ };
3819
+ }
3820
+ }
3821
+ };
3822
+ /** Statuses {@link probeEffort} reports for failures the transport raised pre-network. */
3823
+ function defaultStatusForProbe(code) {
3824
+ switch (code) {
3825
+ case "UNSUPPORTED_REASONING_EFFORT":
3826
+ case "UNSUPPORTED_CONTENT":
3827
+ case "INVALID_REQUEST": return 400;
3828
+ case "MISSING_CREDENTIAL":
3829
+ case "AUTH": return 401;
3830
+ case "RATE_LIMIT": return 429;
3831
+ case "QUOTA": return 402;
3832
+ default: return 502;
3833
+ }
3834
+ }
3835
+ /**
3836
+ * Translate one Qoder catalog row into the plugin's model-info shape.
3837
+ *
3838
+ * The Qoder vocabulary carries less than the WorkBuddy one did — no badges,
3839
+ * no promotions, no free/credits strings — so the billing section reports the
3840
+ * price factor only, spelled `x<n>`, and says so honestly when the upstream
3841
+ * reported nothing (`rateUnknown`). Context capacity comes from the effective
3842
+ * window, with the declared options kept for the card and the maximum-window
3843
+ * preference; a row with no capacity at all takes the transport's own default.
3844
+ */
3845
+ function modelInfoOf(model) {
3846
+ const options = Object.values(model.contextOptions ?? {}).map((option) => option.tokenCount).filter((count) => typeof count === "number" && Number.isFinite(count) && count > 0);
3847
+ const supportedContextWindows = [...new Set(options)].sort((left, right) => left - right);
3848
+ const defaultOptions = Object.values(model.contextOptions ?? {}).filter((option) => option.isDefault === true);
3849
+ const contextWindow = model.contextWindow ?? (defaultOptions.length === 1 ? defaultOptions[0]?.tokenCount : void 0) ?? DEFAULT_CONTEXT_WINDOW;
3850
+ const reasoning = model.isReasoning === true || model.supportsEffort === true ? {
3851
+ supports: true,
3852
+ ...model.reasoningEfforts === void 0 || model.reasoningEfforts.length === 0 ? {} : { supportedEfforts: model.reasoningEfforts.map((effort) => effort.id) },
3853
+ ...model.defaultReasoningEffort === void 0 ? {} : { defaultEffort: model.defaultReasoningEffort },
3854
+ canDisableThinking: false
3855
+ } : void 0;
3856
+ const billing = model.priceFactor === void 0 ? {
3857
+ free: false,
3858
+ rateUnknown: true
3859
+ } : model.priceFactor === 0 ? {
3860
+ credits: "x0",
3861
+ free: true
3862
+ } : {
3863
+ credits: `x${model.priceFactor}`,
3864
+ free: false
3865
+ };
3866
+ return {
3867
+ id: model.id,
3868
+ name: model.name === "" ? model.id : model.name,
3869
+ contextWindow,
3870
+ ...defaultOptions.length === 1 && defaultOptions[0]?.tokenCount !== void 0 ? { defaultContextWindow: defaultOptions[0].tokenCount } : {},
3871
+ ...supportedContextWindows.length > 1 ? { supportedContextWindows } : {},
3872
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
3873
+ supportsImages: model.supportsImages === true,
3874
+ ...reasoning === void 0 ? {} : { reasoning },
3875
+ billing,
3876
+ ...model.source === void 0 ? {} : { source: model.source }
3877
+ };
3878
+ }
3879
+ /** The transport's own output cap for rows that declare none. */
3880
+ const DEFAULT_MAX_TOKENS = 32768;
3881
+ /** A conservative input budget for rows that declare none. */
3882
+ const DEFAULT_CONTEXT_WINDOW = 18e4;
3883
+ //#endregion
3884
+ //#region src/version.ts
3885
+ const QODER_CONNECT_VERSION = "0.1.0";
3886
+ //#endregion
3887
+ //#region src/host-heartbeat.ts
3888
+ /**
3889
+ * Host-side heartbeat: a small JSON file written into the plugin's data
3890
+ * directory once the `qoder` providers are registered. The status CLI reads it
3891
+ * to report whether the host bundle is alive, independent of the browser card.
3892
+ *
3893
+ * The browser (client) bundle cannot write files; its health is reported
3894
+ * only through `console.error` on failure (see `src/client/index.tsx`).
3895
+ * This asymmetry is intentional: the host is the load-bearing half, and
3896
+ * a missing heartbeat unambiguously means the host never started.
3897
+ *
3898
+ * @module dsh-qoder-connect/host-heartbeat
3899
+ */
3900
+ /** Basename of the host heartbeat file inside the plugin's state directory. */
3901
+ const QODER_HOST_HEARTBEAT_FILENAME = ".qoder-host-heartbeat.json";
3902
+ /** Current on-disk heartbeat format; readers reject others. */
3903
+ const HEARTBEAT_FORMAT_VERSION = 1;
3904
+ /** Absolute path of the host heartbeat file. */
3905
+ function qoderHostHeartbeatPath() {
3906
+ return join(qoderStateDir(), QODER_HOST_HEARTBEAT_FILENAME);
3907
+ }
3908
+ /**
3909
+ * Write (or overwrite) the heartbeat after the host bundle registered the
3910
+ * provider. A failed write is non-fatal: the host is already running, and
3911
+ * the status CLI will simply report "heartbeat missing" rather than failing.
3912
+ */
3913
+ async function writeHostHeartbeat() {
3914
+ const document = {
3915
+ version: HEARTBEAT_FORMAT_VERSION,
3916
+ package: "dsh-qoder-connect",
3917
+ pluginVersion: QODER_CONNECT_VERSION,
3918
+ registeredAt: Date.now(),
3919
+ pid: process.pid
3920
+ };
3921
+ try {
3922
+ await mkdir(qoderStateDir(), { recursive: true });
3923
+ await writeFile(qoderHostHeartbeatPath(), JSON.stringify(document), "utf8");
3924
+ } catch {}
3925
+ }
3926
+ /** Remove the heartbeat on plugin disposal so a stale file does not linger. */
3927
+ async function clearHostHeartbeat() {
3928
+ try {
3929
+ await rm(qoderHostHeartbeatPath(), { force: true });
3930
+ } catch {}
3931
+ }
3932
+ /** Read and validate the heartbeat; returns `undefined` when absent or malformed. */
3933
+ async function readHostHeartbeat() {
3934
+ let raw;
3935
+ try {
3936
+ raw = await readFile(qoderHostHeartbeatPath(), "utf8");
3937
+ } catch {
3938
+ return;
3939
+ }
3940
+ try {
3941
+ const parsed = JSON.parse(raw);
3942
+ if (parsed.version === HEARTBEAT_FORMAT_VERSION && parsed.package === "dsh-qoder-connect" && typeof parsed.registeredAt === "number" && typeof parsed.pid === "number") return {
3943
+ version: HEARTBEAT_FORMAT_VERSION,
3944
+ package: "dsh-qoder-connect",
3945
+ pluginVersion: typeof parsed.pluginVersion === "string" ? parsed.pluginVersion : "unknown",
3946
+ registeredAt: parsed.registeredAt,
3947
+ pid: parsed.pid
3948
+ };
3949
+ } catch {}
3950
+ }
3951
+ /**
3952
+ * Absolute start time (epoch ms) of the process holding `pid`, or `undefined`
3953
+ * when it cannot be determined (no such PID, platform lacks a readable source).
3954
+ *
3955
+ * - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
3956
+ * `Date.parse` resolves it against the local clock, which matches how
3957
+ * `registeredAt` (a `Date.now()` absolute value) is expressed.
3958
+ * - Windows: WMI `CreationDate` is UTC (`YYYYMMDDHHMMSS.mmm+zzzz`); parsed with
3959
+ * `Date.UTC`, again comparable to `registeredAt`.
3960
+ *
3961
+ * Failures return `undefined` so callers can fall back to plain PID liveness
3962
+ * rather than mis-report a running host as dead.
3963
+ */
3964
+ function processStartTimeMs(pid) {
3965
+ try {
3966
+ if (process.platform === "win32") {
3967
+ const m = execFileSync("wmic", [
3968
+ "process",
3969
+ "where",
3970
+ `processid=${pid}`,
3971
+ "get",
3972
+ "CreationDate"
3973
+ ], {
3974
+ encoding: "utf8",
3975
+ windowsHide: true
3976
+ }).match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{4})/);
3977
+ if (m === null) return void 0;
3978
+ const [, y, mo, d, h, mi, s] = m;
3979
+ const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
3980
+ return Number.isFinite(ms) ? ms : void 0;
3981
+ }
3982
+ const out = execFileSync("ps", [
3983
+ "-o",
3984
+ "lstart=",
3985
+ "-p",
3986
+ String(pid)
3987
+ ], {
3988
+ encoding: "utf8",
3989
+ env: {
3990
+ ...process.env,
3991
+ LC_ALL: "C",
3992
+ LANG: "C"
3993
+ }
3994
+ }).trim();
3995
+ if (out === "") return void 0;
3996
+ const ms = Date.parse(out);
3997
+ return Number.isFinite(ms) ? ms : void 0;
3998
+ } catch {
3999
+ return;
4000
+ }
4001
+ }
4002
+ /**
4003
+ * Whether the heartbeat's PID is still alive *and* still the same process that
4004
+ * registered it. A stale heartbeat (host crashed without clearing the file)
4005
+ * is distinguished from a live host by two checks:
4006
+ *
4007
+ * 1. `process.kill(pid, 0)` — the PID exists (signal 0 tests existence).
4008
+ * 2. The process holding that PID started at or before `registeredAt`. A host
4009
+ * that registered the heartbeat must have been started before writing it,
4010
+ * so `start <= registeredAt`; a recycled PID belongs to an unrelated process
4011
+ * started after the host died, so `start > registeredAt` correctly reads dead.
4012
+ *
4013
+ * PID-only detection is not enough: after a crash the OS may hand the same PID
4014
+ * to an unrelated process, and the un-cleared stale heartbeat would otherwise
4015
+ * produce a false "Host running". When the process start time cannot be read
4016
+ * (e.g. unsupported platform) the check degrades to plain PID liveness.
4017
+ */
4018
+ function isHeartbeatProcessAlive(heartbeat) {
4019
+ try {
4020
+ process.kill(heartbeat.pid, 0);
4021
+ } catch {
4022
+ return false;
4023
+ }
4024
+ const startAtMs = processStartTimeMs(heartbeat.pid);
4025
+ if (startAtMs === void 0) return true;
4026
+ return startAtMs <= heartbeat.registeredAt;
4027
+ }
4028
+ //#endregion
4029
+ //#region src/variants.ts
4030
+ /**
4031
+ * The two Qoder products this one plugin serves.
4032
+ *
4033
+ * Both are the same upstream in different regions, and they differ by route
4034
+ * (endpoints per `src/qoder/transport/endpoints.ts`), file identity, and display
4035
+ * identity. Everything that varies between them is collected here as one
4036
+ * descriptor, so no module has to carry its own `if (international)` branch and
4037
+ * a third variant would be a data change rather than a refactor.
4038
+ *
4039
+ * Each variant holds its own Personal Access Token: a token minted on
4040
+ * qoder.com does not work against the China deployment and vice versa, and the
4041
+ * two files, routes, and catalogs never cross.
4042
+ *
4043
+ * This module is host-side (it names files and routes). The browser half takes
4044
+ * the same ids and routes from the Node-free `status-paths.ts`, which stays the
4045
+ * single source shared by both halves.
4046
+ *
4047
+ * @module dsh-qoder-connect/variants
4048
+ */
4049
+ /** China Qoder first: the no-suffix arm keeps the plugin's primary routes. */
4050
+ const QODER_VARIANTS = [{
4051
+ id: "qoder",
4052
+ displayName: "Qoder",
4053
+ appName: "Qoder",
4054
+ region: "china",
4055
+ ownFilename: ".qoder-auth.json",
4056
+ probeFilename: ".qoder-probe.json",
4057
+ catalogFilename: ".qoder-catalog.json",
4058
+ statusPath: QODER_STATUS_PATH,
4059
+ probePath: QODER_PROBE_PATH,
4060
+ authPath: QODER_AUTH_PATH
4061
+ }, {
4062
+ id: "qoder-global",
4063
+ displayName: "Qoder Global",
4064
+ appName: "Qoder Global",
4065
+ region: "global",
4066
+ ownFilename: ".qoder-global-auth.json",
4067
+ probeFilename: ".qoder-global-probe.json",
4068
+ catalogFilename: ".qoder-global-catalog.json",
4069
+ statusPath: QODER_GLOBAL_STATUS_PATH,
4070
+ probePath: QODER_GLOBAL_PROBE_PATH,
4071
+ authPath: QODER_GLOBAL_AUTH_PATH
4072
+ }];
4073
+ /** The China variant; the plugin's default and compatibility anchor. */
4074
+ const CHINA_VARIANT = QODER_VARIANTS[0];
4075
+ /** The international variant. */
4076
+ const GLOBAL_VARIANT = QODER_VARIANTS[1];
4077
+ /** Look up a variant by provider id. */
4078
+ function variantFor(id) {
4079
+ return QODER_VARIANTS.find((variant) => variant.id === id);
4080
+ }
4081
+ //#endregion
4082
+ export { QODER_GLOBAL_AUTH_PATH as A, qoderCredentialIdentity as B, probeModel as C, FALLBACK_QODER_MODELS as D, qoderCatalogPath as E, QODER_AUTH_FILENAME as F, qoderPluginDataDir as G, QODER_DATA_DIR_ENV as H, QODER_PAT_ENV_CN as I, qoderStateDir as K, QODER_PAT_ENV_GLOBAL as L, QODER_GLOBAL_STATUS_PATH as M, QODER_PROBE_PATH as N, QoderCatalog as O, QODER_STATUS_PATH as P, QoderCredentialStore as R, PROBE_EFFORT_CANDIDATES as S, QoderCatalogStore as T, QODER_DATA_DIR_NAME as U, qoderOwnAuthPath as V, qoderMachineIdPath as W, modelInfoOf as _, QODER_HOST_HEARTBEAT_FILENAME as a, createQoderTransport as b, processStartTimeMs as c, writeHostHeartbeat as d, QODER_CONNECT_VERSION as f, kindFromQoderFailure as g, classifyUpstreamError as h, variantFor as i, QODER_GLOBAL_PROBE_PATH as j, QODER_AUTH_PATH as k, qoderHostHeartbeatPath as l, QoderUpstreamClient as m, GLOBAL_VARIANT as n, clearHostHeartbeat as o, KIND_STATUS as p, QODER_VARIANTS as r, isHeartbeatProcessAlive as s, CHINA_VARIANT as t, readHostHeartbeat as u, normalizeCredits as v, randomSentinel as w, getMachineId as x, validateApiKey as y, patTail as z };