dsh-qoder-connect 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +33 -8
- package/README.md +33 -8
- package/assets/3.png +0 -0
- package/assets/5.png +0 -0
- package/assets/6.png +0 -0
- package/assets/7.png +0 -0
- package/assets/8.png +0 -0
- package/lib/bin.js +109 -13
- package/lib/client.js +301 -117
- package/lib/index.d.ts +152 -22
- package/lib/index.js +369 -398
- package/lib/{variants-COxX6r1j.js → variants-Dl1AbWre.js} +474 -51
- package/package.json +1 -1
|
@@ -215,24 +215,46 @@ function qoderMachineIdPath() {
|
|
|
215
215
|
//#endregion
|
|
216
216
|
//#region src/auth.ts
|
|
217
217
|
/**
|
|
218
|
-
* Qoder credential storage —
|
|
218
|
+
* Qoder credential storage — a list of Personal Access Tokens per variant,
|
|
219
|
+
* one of which is active.
|
|
219
220
|
*
|
|
220
221
|
* Every credential the plugin holds is a PAT the human pasted into the
|
|
221
|
-
* variant's card (or
|
|
222
|
+
* variant's card (or saved by the CLI, or exported into the environment).
|
|
222
223
|
* There is no device flow, no token refresh, and no expiry: a PAT is valid
|
|
223
224
|
* until the user revokes it, so this store only reads, writes, and deletes a
|
|
224
225
|
* small JSON document.
|
|
225
226
|
*
|
|
227
|
+
* WHAT IS PER-ACCOUNT AND WHAT IS NOT
|
|
228
|
+
*
|
|
229
|
+
* Only the *credential* is per-account. Every setting the cards expose —
|
|
230
|
+
* disabled models, the maximum-context preference, per-model window
|
|
231
|
+
* overrides, probe consent, sidebar quota, check-in toggles — lives in the
|
|
232
|
+
* plugin's own settings file and is shared by every saved PAT. Switching
|
|
233
|
+
* accounts therefore never changes configuration; it only changes which
|
|
234
|
+
* token signs the next request.
|
|
235
|
+
*
|
|
236
|
+
* FILE
|
|
237
|
+
*
|
|
226
238
|
* One file per variant inside the plugin's data directory
|
|
227
239
|
* (`<.dsh-qoder-connect>/`). The on-disk schema is
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
240
|
+
*
|
|
241
|
+
* {version: 3, activeId, credentials: [{id, pat, region, savedAt, label?}]}
|
|
242
|
+
*
|
|
243
|
+
* `id` is a one-way hash prefix of the token, so re-pasting the same token
|
|
244
|
+
* updates its own entry instead of piling up duplicates, and the id can be
|
|
245
|
+
* shown (in a select, a log line, or a test) without leaking the secret.
|
|
246
|
+
* `label` is optional display copy stored at save time; it never reaches the
|
|
247
|
+
* upstream.
|
|
248
|
+
*
|
|
249
|
+
* A version-2 document (`{version: 2, pat, region, savedAt}`) is read as a
|
|
250
|
+
* one-entry list and rewritten as v3 on the next mutation. A WorkBuddy-era
|
|
251
|
+
* document (it carries `accessToken` and no `pat`) is not a credential this
|
|
252
|
+
* plugin can use in any form — it is reported, never migrated.
|
|
231
253
|
*
|
|
232
254
|
* @module dsh-qoder-connect/auth
|
|
233
255
|
*/
|
|
234
256
|
/** The on-disk document version this writer produces. */
|
|
235
|
-
const AUTH_SCHEMA_VERSION =
|
|
257
|
+
const AUTH_SCHEMA_VERSION = 3;
|
|
236
258
|
/** Environment fallback for the global arm when no file exists. */
|
|
237
259
|
const QODER_PAT_ENV_GLOBAL = "QODER_PERSONAL_ACCESS_TOKEN";
|
|
238
260
|
/** Environment fallback for the China arm when no file exists. */
|
|
@@ -245,22 +267,56 @@ function patTail(pat) {
|
|
|
245
267
|
return trimmed.length >= 4 ? trimmed.slice(-4) : void 0;
|
|
246
268
|
}
|
|
247
269
|
/**
|
|
270
|
+
* Stable local id for one token.
|
|
271
|
+
*
|
|
272
|
+
* A PAT is a bearer secret, so the id is a one-way hash prefix rather than
|
|
273
|
+
* any suffix of it: surfaces that show the id (the select, status documents,
|
|
274
|
+
* logs) leak nothing usable, while two saves of the same token still resolve
|
|
275
|
+
* to the same entry.
|
|
276
|
+
*/
|
|
277
|
+
function qoderCredentialId(pat) {
|
|
278
|
+
return createHash("sha256").update(pat).digest("hex").slice(0, 12);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
248
281
|
* Stable identity key for a credential, used by probe records and saved
|
|
249
282
|
* catalogs.
|
|
250
283
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* nothing usable, while two reads of the same token still correlate locally.
|
|
284
|
+
* Deliberately wider than the local id: these keys address stored data, not
|
|
285
|
+
* UI, and 16 hex characters keep two tokens from ever sharing a bucket.
|
|
254
286
|
*/
|
|
255
287
|
function qoderCredentialIdentity(credential) {
|
|
256
288
|
return `pat:${createHash("sha256").update(credential.pat).digest("hex").slice(0, 16)}`;
|
|
257
289
|
}
|
|
258
|
-
function isRecord$
|
|
290
|
+
function isRecord$2(value) {
|
|
259
291
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
260
292
|
}
|
|
261
293
|
function isRegion(value) {
|
|
262
294
|
return value === "global" || value === "china";
|
|
263
295
|
}
|
|
296
|
+
/** Trim a label and cap it at a length a card row can render. */
|
|
297
|
+
function normalizeLabel(value) {
|
|
298
|
+
if (typeof value !== "string") return void 0;
|
|
299
|
+
const trimmed = value.trim();
|
|
300
|
+
return trimmed === "" ? void 0 : trimmed.slice(0, 48);
|
|
301
|
+
}
|
|
302
|
+
/** Parse one stored entry, dropping anything this store cannot use. */
|
|
303
|
+
function normalizeEntry(value) {
|
|
304
|
+
if (!isRecord$2(value)) return void 0;
|
|
305
|
+
const pat = typeof value["pat"] === "string" ? value["pat"].trim() : "";
|
|
306
|
+
if (pat === "") return void 0;
|
|
307
|
+
const region = value["region"];
|
|
308
|
+
if (!isRegion(region)) return void 0;
|
|
309
|
+
const rawId = typeof value["id"] === "string" ? value["id"].trim() : "";
|
|
310
|
+
const label = normalizeLabel(value["label"]);
|
|
311
|
+
const savedAt = typeof value["savedAt"] === "number" && Number.isFinite(value["savedAt"]) ? value["savedAt"] : void 0;
|
|
312
|
+
return {
|
|
313
|
+
id: rawId === "" ? qoderCredentialId(pat) : rawId,
|
|
314
|
+
pat,
|
|
315
|
+
region,
|
|
316
|
+
...savedAt === void 0 ? {} : { savedAtMs: savedAt },
|
|
317
|
+
...label === void 0 ? {} : { label }
|
|
318
|
+
};
|
|
319
|
+
}
|
|
264
320
|
var QoderCredentialStore = class {
|
|
265
321
|
variant;
|
|
266
322
|
ownPath;
|
|
@@ -279,14 +335,25 @@ var QoderCredentialStore = class {
|
|
|
279
335
|
/**
|
|
280
336
|
* The credential currently in effect, or `undefined` when there is none.
|
|
281
337
|
*
|
|
282
|
-
* Resolution order: the
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* lives until
|
|
338
|
+
* Resolution order: the active saved entry (or the first one when the
|
|
339
|
+
* pointer is stale), then the arm's environment variable (only when no
|
|
340
|
+
* saved entry exists — a saved token always wins over a stray env), then
|
|
341
|
+
* nothing. There is no refresh and no expiry check: a PAT lives until
|
|
342
|
+
* revoked.
|
|
286
343
|
*/
|
|
287
344
|
async current() {
|
|
288
345
|
const stored = await this.readOwn();
|
|
289
|
-
if (stored !== void 0
|
|
346
|
+
if (stored !== void 0 && stored.entries.length > 0) {
|
|
347
|
+
const entry = stored.entries.find((candidate) => candidate.id === stored.activeId) ?? stored.entries[0];
|
|
348
|
+
return {
|
|
349
|
+
id: entry.id,
|
|
350
|
+
pat: entry.pat,
|
|
351
|
+
region: entry.region,
|
|
352
|
+
...entry.savedAtMs === void 0 ? {} : { savedAtMs: entry.savedAtMs },
|
|
353
|
+
...entry.label === void 0 ? {} : { label: entry.label },
|
|
354
|
+
source: "card"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
290
357
|
return this.fromEnvironment();
|
|
291
358
|
}
|
|
292
359
|
/**
|
|
@@ -305,23 +372,94 @@ var QoderCredentialStore = class {
|
|
|
305
372
|
async patPromise() {
|
|
306
373
|
return (await this.resolve()).pat;
|
|
307
374
|
}
|
|
375
|
+
/** Every saved entry (tokens included; host-side callers only). */
|
|
376
|
+
async entries() {
|
|
377
|
+
return (await this.readOwn())?.entries ?? [];
|
|
378
|
+
}
|
|
308
379
|
/**
|
|
309
|
-
*
|
|
310
|
-
*
|
|
380
|
+
* Add a token and make it active.
|
|
381
|
+
*
|
|
382
|
+
* Re-saving an existing token updates that entry (its saved time, and its
|
|
383
|
+
* label when one is supplied) instead of appending a duplicate — the id is
|
|
384
|
+
* derived from the token, so "same PAT" is the same entry by construction.
|
|
385
|
+
* The caller is responsible for having validated the token against the
|
|
386
|
+
* region first (`validateApiKey` upstream).
|
|
387
|
+
*
|
|
388
|
+
* @param pat - the token to store.
|
|
389
|
+
* @param label - optional display label for the select row.
|
|
390
|
+
* @returns the stored entry.
|
|
311
391
|
*/
|
|
312
|
-
async
|
|
392
|
+
async add(pat, label) {
|
|
313
393
|
const trimmed = pat.trim();
|
|
314
394
|
if (trimmed === "") throw new Error("qoder: cannot save an empty Personal Access Token");
|
|
315
|
-
const
|
|
395
|
+
const entries = (await this.readOwn())?.entries ?? [];
|
|
396
|
+
const id = qoderCredentialId(trimmed);
|
|
397
|
+
const index = entries.findIndex((entry) => entry.id === id);
|
|
398
|
+
const existing = index === -1 ? void 0 : entries[index];
|
|
399
|
+
const nextLabel = normalizeLabel(label) ?? existing?.label;
|
|
400
|
+
const next = {
|
|
401
|
+
id,
|
|
316
402
|
pat: trimmed,
|
|
317
403
|
region: this.variant.region,
|
|
318
404
|
savedAtMs: Date.now(),
|
|
405
|
+
...nextLabel === void 0 ? {} : { label: nextLabel }
|
|
406
|
+
};
|
|
407
|
+
if (index === -1) entries.push(next);
|
|
408
|
+
else entries[index] = next;
|
|
409
|
+
await this.writeDocument(entries, id);
|
|
410
|
+
return next;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Make one saved entry active.
|
|
414
|
+
*
|
|
415
|
+
* @param id - the entry id.
|
|
416
|
+
* @returns the activated entry, or `undefined` when no entry has that id.
|
|
417
|
+
*/
|
|
418
|
+
async activate(id) {
|
|
419
|
+
const stored = await this.readOwn();
|
|
420
|
+
if (stored === void 0) return void 0;
|
|
421
|
+
const entry = stored.entries.find((candidate) => candidate.id === id);
|
|
422
|
+
if (entry === void 0) return void 0;
|
|
423
|
+
await this.writeDocument(stored.entries, id);
|
|
424
|
+
return entry;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Remove one saved entry.
|
|
428
|
+
*
|
|
429
|
+
* The active pointer moves to the first remaining entry; removing the last
|
|
430
|
+
* entry deletes the file, exactly like {@link clear} — a saved token is the
|
|
431
|
+
* only thing that shadows the environment fallback.
|
|
432
|
+
*
|
|
433
|
+
* @param id - the entry id.
|
|
434
|
+
* @returns the removed entry, or `undefined` when no entry has that id.
|
|
435
|
+
*/
|
|
436
|
+
async remove(id) {
|
|
437
|
+
const stored = await this.readOwn();
|
|
438
|
+
if (stored === void 0) return void 0;
|
|
439
|
+
const index = stored.entries.findIndex((entry) => entry.id === id);
|
|
440
|
+
if (index === -1) return void 0;
|
|
441
|
+
const [removed] = stored.entries.splice(index, 1);
|
|
442
|
+
const activeId = stored.activeId === id ? stored.entries[0]?.id : stored.activeId;
|
|
443
|
+
await this.writeDocument(stored.entries, activeId);
|
|
444
|
+
return removed;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Persist a token for this variant and make it active.
|
|
448
|
+
*
|
|
449
|
+
* Kept for the CLI and older call sites: it is {@link add} behind the
|
|
450
|
+
* runtime-credential shape they expect.
|
|
451
|
+
*/
|
|
452
|
+
async save(pat, source = "card") {
|
|
453
|
+
const entry = await this.add(pat);
|
|
454
|
+
return {
|
|
455
|
+
id: entry.id,
|
|
456
|
+
pat: entry.pat,
|
|
457
|
+
region: entry.region,
|
|
458
|
+
...entry.savedAtMs === void 0 ? {} : { savedAtMs: entry.savedAtMs },
|
|
319
459
|
source
|
|
320
460
|
};
|
|
321
|
-
await this.writeOwn(credential);
|
|
322
|
-
return credential;
|
|
323
461
|
}
|
|
324
|
-
/** Remove
|
|
462
|
+
/** Remove every stored credential. A missing file is not an error. */
|
|
325
463
|
async clear() {
|
|
326
464
|
try {
|
|
327
465
|
await rm(this.ownPath, { force: true });
|
|
@@ -337,7 +475,35 @@ var QoderCredentialStore = class {
|
|
|
337
475
|
filePath: this.ownPath,
|
|
338
476
|
region: this.variant.region
|
|
339
477
|
};
|
|
340
|
-
const
|
|
478
|
+
const stored = await this.readOwn();
|
|
479
|
+
const entries = stored?.entries ?? [];
|
|
480
|
+
if (entries.length > 0) {
|
|
481
|
+
const active = entries.find((entry) => entry.id === stored?.activeId) ?? entries[0];
|
|
482
|
+
const tail = patTail(active.pat);
|
|
483
|
+
const profiles = [...entries].sort(newestFirst$1).map((entry) => {
|
|
484
|
+
const entryTail = patTail(entry.pat);
|
|
485
|
+
return {
|
|
486
|
+
id: entry.id,
|
|
487
|
+
...entry.label === void 0 ? {} : { label: entry.label },
|
|
488
|
+
...entryTail === void 0 ? {} : { patTail: entryTail },
|
|
489
|
+
...entry.savedAtMs === void 0 ? {} : { savedAtMs: entry.savedAtMs }
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
state: "configured",
|
|
494
|
+
...base,
|
|
495
|
+
pat: {
|
|
496
|
+
id: active.id,
|
|
497
|
+
...active.label === void 0 ? {} : { label: active.label },
|
|
498
|
+
source: "card",
|
|
499
|
+
...active.savedAtMs === void 0 ? {} : { savedAtMs: active.savedAtMs },
|
|
500
|
+
...tail === void 0 ? {} : { patTail: tail }
|
|
501
|
+
},
|
|
502
|
+
activeId: active.id,
|
|
503
|
+
profiles
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const credential = await this.fromEnvironment();
|
|
341
507
|
if (credential === void 0) return {
|
|
342
508
|
state: "missing",
|
|
343
509
|
...base,
|
|
@@ -349,7 +515,6 @@ var QoderCredentialStore = class {
|
|
|
349
515
|
...base,
|
|
350
516
|
pat: {
|
|
351
517
|
source: credential.source,
|
|
352
|
-
...credential.savedAtMs === void 0 ? {} : { savedAtMs: credential.savedAtMs },
|
|
353
518
|
...tail === void 0 ? {} : { patTail: tail }
|
|
354
519
|
}
|
|
355
520
|
};
|
|
@@ -370,7 +535,7 @@ var QoderCredentialStore = class {
|
|
|
370
535
|
let text;
|
|
371
536
|
try {
|
|
372
537
|
text = await readFile(this.ownPath, "utf8");
|
|
373
|
-
} catch
|
|
538
|
+
} catch {
|
|
374
539
|
this.pendingLegacyReason = void 0;
|
|
375
540
|
return;
|
|
376
541
|
}
|
|
@@ -381,11 +546,11 @@ var QoderCredentialStore = class {
|
|
|
381
546
|
this.pendingLegacyReason = void 0;
|
|
382
547
|
return;
|
|
383
548
|
}
|
|
384
|
-
if (!isRecord$
|
|
549
|
+
if (!isRecord$2(parsed)) {
|
|
385
550
|
this.pendingLegacyReason = void 0;
|
|
386
551
|
return;
|
|
387
552
|
}
|
|
388
|
-
if (typeof parsed["accessToken"] === "string" && parsed["pat"] === void 0) {
|
|
553
|
+
if (typeof parsed["accessToken"] === "string" && parsed["pat"] === void 0 && parsed["credentials"] === void 0) {
|
|
389
554
|
this.pendingLegacyReason = "stale WorkBuddy credential file; save a Qoder Personal Access Token instead";
|
|
390
555
|
if (!this.legacyWarned) {
|
|
391
556
|
this.legacyWarned = true;
|
|
@@ -394,23 +559,49 @@ var QoderCredentialStore = class {
|
|
|
394
559
|
return;
|
|
395
560
|
}
|
|
396
561
|
this.pendingLegacyReason = void 0;
|
|
562
|
+
if (Array.isArray(parsed["credentials"])) {
|
|
563
|
+
const byId = /* @__PURE__ */ new Map();
|
|
564
|
+
for (const raw of parsed["credentials"]) {
|
|
565
|
+
const entry = normalizeEntry(raw);
|
|
566
|
+
if (entry !== void 0) byId.set(entry.id, entry);
|
|
567
|
+
}
|
|
568
|
+
const entries = [...byId.values()];
|
|
569
|
+
const requested = typeof parsed["activeId"] === "string" ? parsed["activeId"] : void 0;
|
|
570
|
+
return {
|
|
571
|
+
entries,
|
|
572
|
+
activeId: requested !== void 0 && entries.some((entry) => entry.id === requested) ? requested : entries[0]?.id
|
|
573
|
+
};
|
|
574
|
+
}
|
|
397
575
|
const pat = typeof parsed["pat"] === "string" ? parsed["pat"].trim() : "";
|
|
398
576
|
if (pat === "") return void 0;
|
|
399
577
|
const region = isRegion(parsed["region"]) ? parsed["region"] : this.variant.region;
|
|
400
578
|
const savedAt = typeof parsed["savedAt"] === "number" && Number.isFinite(parsed["savedAt"]) ? parsed["savedAt"] : void 0;
|
|
401
|
-
|
|
579
|
+
const entry = {
|
|
580
|
+
id: qoderCredentialId(pat),
|
|
402
581
|
pat,
|
|
403
582
|
region,
|
|
404
|
-
...savedAt === void 0 ? {} : { savedAtMs: savedAt }
|
|
405
|
-
|
|
583
|
+
...savedAt === void 0 ? {} : { savedAtMs: savedAt }
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
entries: [entry],
|
|
587
|
+
activeId: entry.id
|
|
406
588
|
};
|
|
407
589
|
}
|
|
408
|
-
async
|
|
590
|
+
async writeDocument(entries, activeId) {
|
|
591
|
+
if (entries.length === 0) {
|
|
592
|
+
await this.clear();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
409
595
|
const document = {
|
|
410
596
|
version: AUTH_SCHEMA_VERSION,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
597
|
+
activeId: activeId !== void 0 && entries.some((entry) => entry.id === activeId) ? activeId : entries[0].id,
|
|
598
|
+
credentials: entries.map((entry) => ({
|
|
599
|
+
id: entry.id,
|
|
600
|
+
pat: entry.pat,
|
|
601
|
+
region: entry.region,
|
|
602
|
+
savedAt: entry.savedAtMs ?? Date.now(),
|
|
603
|
+
...entry.label === void 0 ? {} : { label: entry.label }
|
|
604
|
+
}))
|
|
414
605
|
};
|
|
415
606
|
const directory = dirname(this.ownPath);
|
|
416
607
|
try {
|
|
@@ -426,6 +617,10 @@ var QoderCredentialStore = class {
|
|
|
426
617
|
this.pendingLegacyReason = void 0;
|
|
427
618
|
}
|
|
428
619
|
};
|
|
620
|
+
/** Newest first, with a stable tie-break so the select order never jitters. */
|
|
621
|
+
function newestFirst$1(a, b) {
|
|
622
|
+
return (b.savedAtMs ?? 0) - (a.savedAtMs ?? 0) || a.id.localeCompare(b.id);
|
|
623
|
+
}
|
|
429
624
|
/**
|
|
430
625
|
* The plugin-owned credential path for one variant's default filename.
|
|
431
626
|
* Kept as a free function for the CLI, which builds no store per display line.
|
|
@@ -4105,7 +4300,7 @@ const IMAGE_MEDIA_TYPES = [
|
|
|
4105
4300
|
"image/webp",
|
|
4106
4301
|
"image/gif"
|
|
4107
4302
|
];
|
|
4108
|
-
function isRecord(value) {
|
|
4303
|
+
function isRecord$1(value) {
|
|
4109
4304
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4110
4305
|
}
|
|
4111
4306
|
function requiredString(value, what) {
|
|
@@ -4146,7 +4341,7 @@ async function openAiContentBlocks(content, role, imagesAs) {
|
|
|
4146
4341
|
throw new UpstreamRequestError(`${role} message content must be a string or a parts array`);
|
|
4147
4342
|
})();
|
|
4148
4343
|
for (const raw of parts) {
|
|
4149
|
-
if (!isRecord(raw) || typeof raw["type"] !== "string") throw new UpstreamRequestError(`${role} message content part is malformed`);
|
|
4344
|
+
if (!isRecord$1(raw) || typeof raw["type"] !== "string") throw new UpstreamRequestError(`${role} message content part is malformed`);
|
|
4150
4345
|
switch (raw["type"]) {
|
|
4151
4346
|
case "text":
|
|
4152
4347
|
blocks.push({
|
|
@@ -4157,7 +4352,7 @@ async function openAiContentBlocks(content, role, imagesAs) {
|
|
|
4157
4352
|
case "image_url": {
|
|
4158
4353
|
if (role !== "user") throw new UpstreamRequestError("image parts are valid only in user messages");
|
|
4159
4354
|
const imageUrl = raw["image_url"];
|
|
4160
|
-
const url = isRecord(imageUrl) && typeof imageUrl["url"] === "string" ? imageUrl["url"] : "";
|
|
4355
|
+
const url = isRecord$1(imageUrl) && typeof imageUrl["url"] === "string" ? imageUrl["url"] : "";
|
|
4161
4356
|
if (!url.toLowerCase().startsWith("data:")) throw new UpstreamRequestError("image_url must be an inline data: URL; remote image URLs are not fetched");
|
|
4162
4357
|
if (imagesAs === void 0) throw new UpstreamRequestError("image input requires the DSH attachment service");
|
|
4163
4358
|
const decoded = decodeDataImage(url);
|
|
@@ -4186,14 +4381,14 @@ async function toGenerateOptions(bodyJson, context) {
|
|
|
4186
4381
|
} catch {
|
|
4187
4382
|
throw new UpstreamRequestError("request body is not valid JSON");
|
|
4188
4383
|
}
|
|
4189
|
-
if (!isRecord(parsed)) throw new UpstreamRequestError("request body must be a JSON object");
|
|
4384
|
+
if (!isRecord$1(parsed)) throw new UpstreamRequestError("request body must be a JSON object");
|
|
4190
4385
|
const model = requiredString(parsed["model"], "model");
|
|
4191
4386
|
const rawMessages = parsed["messages"];
|
|
4192
4387
|
if (!Array.isArray(rawMessages) || rawMessages.length === 0) throw new UpstreamRequestError("messages must be a non-empty array");
|
|
4193
4388
|
const systemParts = [];
|
|
4194
4389
|
const messages = [];
|
|
4195
4390
|
for (const raw of rawMessages) {
|
|
4196
|
-
if (!isRecord(raw)) throw new UpstreamRequestError("each message must be an object");
|
|
4391
|
+
if (!isRecord$1(raw)) throw new UpstreamRequestError("each message must be an object");
|
|
4197
4392
|
const role = raw["role"];
|
|
4198
4393
|
switch (role) {
|
|
4199
4394
|
case "system":
|
|
@@ -4219,8 +4414,8 @@ async function toGenerateOptions(bodyJson, context) {
|
|
|
4219
4414
|
const content = await openAiContentBlocks(raw["content"], "assistant", void 0);
|
|
4220
4415
|
const rawCalls = raw["tool_calls"];
|
|
4221
4416
|
if (Array.isArray(rawCalls)) for (const rawCall of rawCalls) {
|
|
4222
|
-
if (!isRecord(rawCall)) throw new UpstreamRequestError("each tool call must be an object");
|
|
4223
|
-
const fn = isRecord(rawCall["function"]) ? rawCall["function"] : rawCall;
|
|
4417
|
+
if (!isRecord$1(rawCall)) throw new UpstreamRequestError("each tool call must be an object");
|
|
4418
|
+
const fn = isRecord$1(rawCall["function"]) ? rawCall["function"] : rawCall;
|
|
4224
4419
|
content.push({
|
|
4225
4420
|
type: "tool-call",
|
|
4226
4421
|
id: ToolCallId(requiredString(rawCall["id"], "tool call id")),
|
|
@@ -4244,8 +4439,8 @@ async function toGenerateOptions(bodyJson, context) {
|
|
|
4244
4439
|
let text;
|
|
4245
4440
|
if (typeof content === "string") text = content;
|
|
4246
4441
|
else if (Array.isArray(content)) {
|
|
4247
|
-
text = content.filter((part) => isRecord(part) && part["type"] === "text").map((part) => typeof part["text"] === "string" ? part["text"] : "").join("");
|
|
4248
|
-
if (content.some((part) => isRecord(part) && part["type"] !== "text")) throw new UpstreamRequestError("tool result content is limited to text");
|
|
4442
|
+
text = content.filter((part) => isRecord$1(part) && part["type"] === "text").map((part) => typeof part["text"] === "string" ? part["text"] : "").join("");
|
|
4443
|
+
if (content.some((part) => isRecord$1(part) && part["type"] !== "text")) throw new UpstreamRequestError("tool result content is limited to text");
|
|
4249
4444
|
} else throw new UpstreamRequestError("tool message content must be a string or a text-parts array");
|
|
4250
4445
|
messages.push(createToolResultMessage({
|
|
4251
4446
|
callId: ToolCallId(callId),
|
|
@@ -4262,13 +4457,13 @@ async function toGenerateOptions(bodyJson, context) {
|
|
|
4262
4457
|
}
|
|
4263
4458
|
if (messages.length === 0) throw new UpstreamRequestError("the request carries no model-facing message");
|
|
4264
4459
|
const tools = Array.isArray(parsed["tools"]) ? parsed["tools"].map((raw) => {
|
|
4265
|
-
if (!isRecord(raw)) throw new UpstreamRequestError("each tool must be an object");
|
|
4266
|
-
const fn = isRecord(raw["function"]) ? raw["function"] : raw;
|
|
4267
|
-
if (!isRecord(fn)) throw new UpstreamRequestError("each tool must carry a function object");
|
|
4460
|
+
if (!isRecord$1(raw)) throw new UpstreamRequestError("each tool must be an object");
|
|
4461
|
+
const fn = isRecord$1(raw["function"]) ? raw["function"] : raw;
|
|
4462
|
+
if (!isRecord$1(fn)) throw new UpstreamRequestError("each tool must carry a function object");
|
|
4268
4463
|
return {
|
|
4269
4464
|
name: requiredString(fn["name"], "tool name"),
|
|
4270
4465
|
description: typeof fn["description"] === "string" ? fn["description"] : "",
|
|
4271
|
-
parameters: isRecord(fn["parameters"]) ? fn["parameters"] : {}
|
|
4466
|
+
parameters: isRecord$1(fn["parameters"]) ? fn["parameters"] : {}
|
|
4272
4467
|
};
|
|
4273
4468
|
}) : void 0;
|
|
4274
4469
|
const effort = parsed["reasoning_effort"];
|
|
@@ -4430,6 +4625,17 @@ var ChunkEncoder = class {
|
|
|
4430
4625
|
} }), SSE_DONE];
|
|
4431
4626
|
}
|
|
4432
4627
|
};
|
|
4628
|
+
/**
|
|
4629
|
+
* Normalize a provider retry hint for {@link QoderChatResult}.
|
|
4630
|
+
*
|
|
4631
|
+
* Only a positive finite number is a hint: the truncation keeps the shim's
|
|
4632
|
+
* `Retry-After` header an integer number of seconds, and `undefined` keeps the
|
|
4633
|
+
* optional field off the result entirely (no `retryAfterMs: undefined` noise in
|
|
4634
|
+
* the shim's error body).
|
|
4635
|
+
*/
|
|
4636
|
+
function retryHintOf(value) {
|
|
4637
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? { retryAfterMs: Math.floor(value) } : {};
|
|
4638
|
+
}
|
|
4433
4639
|
/** Turn any thrown failure into the shim's classified pre-stream result. */
|
|
4434
4640
|
function failureResult(error) {
|
|
4435
4641
|
if (error instanceof UpstreamRequestError) return {
|
|
@@ -4444,7 +4650,8 @@ function failureResult(error) {
|
|
|
4444
4650
|
ok: false,
|
|
4445
4651
|
status: typeof error.failure.status === "number" && error.failure.status >= 100 && error.failure.status <= 599 ? error.failure.status : KIND_STATUS[kind],
|
|
4446
4652
|
kind,
|
|
4447
|
-
message: error.failure.message
|
|
4653
|
+
message: error.failure.message,
|
|
4654
|
+
...retryHintOf(error.failure.providerRetryAfterMs)
|
|
4448
4655
|
};
|
|
4449
4656
|
}
|
|
4450
4657
|
return {
|
|
@@ -4624,7 +4831,8 @@ var QoderUpstreamClient = class {
|
|
|
4624
4831
|
ok: false,
|
|
4625
4832
|
status: earlyFailure.status ?? KIND_STATUS[kind],
|
|
4626
4833
|
kind,
|
|
4627
|
-
message: earlyFailure.message
|
|
4834
|
+
message: earlyFailure.message,
|
|
4835
|
+
...retryHintOf(earlyFailure.providerRetryAfterMs)
|
|
4628
4836
|
};
|
|
4629
4837
|
}
|
|
4630
4838
|
}
|
|
@@ -4823,8 +5031,223 @@ const DEFAULT_MAX_TOKENS = 32768;
|
|
|
4823
5031
|
/** A conservative input budget for rows that declare none. */
|
|
4824
5032
|
const DEFAULT_CONTEXT_WINDOW = 18e4;
|
|
4825
5033
|
//#endregion
|
|
5034
|
+
//#region src/probe-store.ts
|
|
5035
|
+
/**
|
|
5036
|
+
* Local record of reasoning-effort probes.
|
|
5037
|
+
*
|
|
5038
|
+
* What this stores is an *observation*, never a claim about the upstream: a
|
|
5039
|
+
* model's row is only consulted when the catalog carries no explicit
|
|
5040
|
+
* `supportedEfforts` set, and it always loses to a declared set. The plan this
|
|
5041
|
+
* implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
|
|
5042
|
+
* is invalidated whenever the model's catalog row changes, so every record
|
|
5043
|
+
* carries a fingerprint of the fields the probe depended on.
|
|
5044
|
+
*
|
|
5045
|
+
* The file lives in the plugin's own state directory, never beside the
|
|
5046
|
+
* product's files, and carries no token, prompt, or response body — only
|
|
5047
|
+
* model ids, effort spellings, and timestamps.
|
|
5048
|
+
*
|
|
5049
|
+
* @module dsh-qoder-connect/probe-store
|
|
5050
|
+
*/
|
|
5051
|
+
/** Basename of the China variant's probe record inside the plugin's state dir. */
|
|
5052
|
+
const QODER_PROBE_FILENAME = ".qoder-probe.json";
|
|
5053
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
5054
|
+
const PROBE_FORMAT_VERSION = 1;
|
|
5055
|
+
/**
|
|
5056
|
+
* How long an observation stays usable. Conservative on purpose: the plan's
|
|
5057
|
+
* whole argument is that upstream metadata moves fast, so a result that has
|
|
5058
|
+
* outlived its fingerprint's usefulness should not quietly keep granting a
|
|
5059
|
+
* picker entry.
|
|
5060
|
+
*/
|
|
5061
|
+
const DEFAULT_TTL_MS = 12096e5;
|
|
5062
|
+
/**
|
|
5063
|
+
* Plugin-owned probe record path inside the plugin's state directory.
|
|
5064
|
+
*
|
|
5065
|
+
* One file per variant. The two regions serve overlapping model ids with
|
|
5066
|
+
* different entitlements, and {@link fingerprintModel} covers only
|
|
5067
|
+
* `id`/`reasoning`/`supportsImages` — never the provider — so a single shared
|
|
5068
|
+
* file would let one variant's observation answer for the other. The paths
|
|
5069
|
+
* differ; the format does not.
|
|
5070
|
+
*/
|
|
5071
|
+
function qoderProbePath(filename = QODER_PROBE_FILENAME) {
|
|
5072
|
+
return join(qoderStateDir(), filename);
|
|
5073
|
+
}
|
|
5074
|
+
/**
|
|
5075
|
+
* Fingerprint the catalog fields a probe depends on.
|
|
5076
|
+
*
|
|
5077
|
+
* Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
|
|
5078
|
+
* so a rename or a rate relabel does not throw away a valid observation, and
|
|
5079
|
+
* deliberately includes the whole reasoning object so any change to the
|
|
5080
|
+
* declared shape re-probes.
|
|
5081
|
+
*/
|
|
5082
|
+
function fingerprintModel(info) {
|
|
5083
|
+
const basis = JSON.stringify({
|
|
5084
|
+
id: info.id,
|
|
5085
|
+
reasoning: info.reasoning ?? null,
|
|
5086
|
+
supportsImages: info.supportsImages ?? null
|
|
5087
|
+
});
|
|
5088
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
5089
|
+
}
|
|
5090
|
+
/** Read-and-validate the documents on disk; anything malformed reads as empty. */
|
|
5091
|
+
function readDocument(path) {
|
|
5092
|
+
if (!existsSync(path)) return void 0;
|
|
5093
|
+
let parsed;
|
|
5094
|
+
try {
|
|
5095
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
5096
|
+
} catch {
|
|
5097
|
+
return;
|
|
5098
|
+
}
|
|
5099
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
5100
|
+
const wrapped = parsed;
|
|
5101
|
+
if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
|
|
5102
|
+
const records = wrapped["records"];
|
|
5103
|
+
if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
|
|
5104
|
+
return parsed;
|
|
5105
|
+
}
|
|
5106
|
+
/** One record's shape check; a bad row is dropped rather than trusted. */
|
|
5107
|
+
function isRecord(value) {
|
|
5108
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
5109
|
+
const wrapped = value;
|
|
5110
|
+
const validation = wrapped["validation"];
|
|
5111
|
+
if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
|
|
5112
|
+
if (typeof wrapped["fingerprint"] !== "string") return false;
|
|
5113
|
+
if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
|
|
5114
|
+
if (typeof wrapped["pluginVersion"] !== "string") return false;
|
|
5115
|
+
const efforts = wrapped["efforts"];
|
|
5116
|
+
if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
|
|
5117
|
+
return true;
|
|
5118
|
+
}
|
|
5119
|
+
/**
|
|
5120
|
+
* The plugin's probe records: read once, written atomically, never trusted
|
|
5121
|
+
* across a fingerprint change or past the TTL.
|
|
5122
|
+
*/
|
|
5123
|
+
var QoderProbeStore = class {
|
|
5124
|
+
path;
|
|
5125
|
+
ttlMs;
|
|
5126
|
+
pluginVersion;
|
|
5127
|
+
now;
|
|
5128
|
+
records;
|
|
5129
|
+
constructor(options) {
|
|
5130
|
+
const opts = typeof options === "string" ? {
|
|
5131
|
+
path: options,
|
|
5132
|
+
pluginVersion: "0.0.0"
|
|
5133
|
+
} : options;
|
|
5134
|
+
this.path = opts.path ?? qoderProbePath();
|
|
5135
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
5136
|
+
this.pluginVersion = opts.pluginVersion;
|
|
5137
|
+
this.now = opts.now ?? (() => Date.now());
|
|
5138
|
+
}
|
|
5139
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
5140
|
+
filePath() {
|
|
5141
|
+
return this.path;
|
|
5142
|
+
}
|
|
5143
|
+
load() {
|
|
5144
|
+
if (this.records === void 0) {
|
|
5145
|
+
const document = readDocument(this.path);
|
|
5146
|
+
const records = {};
|
|
5147
|
+
for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
|
|
5148
|
+
this.records = records;
|
|
5149
|
+
}
|
|
5150
|
+
return this.records;
|
|
5151
|
+
}
|
|
5152
|
+
/**
|
|
5153
|
+
* The usable record for a model, or `undefined` when there is none, it is
|
|
5154
|
+
* expired, it was taken against a different catalog row, or it belongs to a
|
|
5155
|
+
* different account.
|
|
5156
|
+
*
|
|
5157
|
+
* @param account - the account in effect, as `uid:enterpriseId`. Records are
|
|
5158
|
+
* only returned for the account that produced them.
|
|
5159
|
+
*/
|
|
5160
|
+
get(modelId, fingerprint, account) {
|
|
5161
|
+
const record = this.load()[modelId];
|
|
5162
|
+
if (record === void 0) return void 0;
|
|
5163
|
+
if (record.fingerprint !== fingerprint) return void 0;
|
|
5164
|
+
if (record.account !== account) return void 0;
|
|
5165
|
+
if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
|
|
5166
|
+
return record;
|
|
5167
|
+
}
|
|
5168
|
+
/**
|
|
5169
|
+
* Store one observation. Only a decisive answer (`validating` /
|
|
5170
|
+
* `non-validating`) replaces an existing decisive record: a transient
|
|
5171
|
+
* `unknown` must not erase knowledge the user already paid for.
|
|
5172
|
+
*/
|
|
5173
|
+
set(modelId, record) {
|
|
5174
|
+
const records = this.load();
|
|
5175
|
+
const existing = records[modelId];
|
|
5176
|
+
if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
|
|
5177
|
+
records[modelId] = record;
|
|
5178
|
+
this.persist();
|
|
5179
|
+
}
|
|
5180
|
+
/** Drop every record; used by the card's explicit "clear" action. */
|
|
5181
|
+
clear() {
|
|
5182
|
+
this.records = {};
|
|
5183
|
+
this.persist();
|
|
5184
|
+
}
|
|
5185
|
+
/**
|
|
5186
|
+
* Drop every record that belongs to one account, leaving the other saved
|
|
5187
|
+
* accounts' observations intact.
|
|
5188
|
+
*
|
|
5189
|
+
* Used when a saved token is removed: its detected effort levels are that
|
|
5190
|
+
* account's data, and no other account can use them. A plain {@link clear}
|
|
5191
|
+
* here would erase the observations of every account the user still has.
|
|
5192
|
+
*/
|
|
5193
|
+
clearFor(account) {
|
|
5194
|
+
const records = this.load();
|
|
5195
|
+
let removed = false;
|
|
5196
|
+
for (const [modelId, record] of Object.entries(records)) {
|
|
5197
|
+
if (record.account !== account) continue;
|
|
5198
|
+
delete records[modelId];
|
|
5199
|
+
removed = true;
|
|
5200
|
+
}
|
|
5201
|
+
if (removed) this.persist();
|
|
5202
|
+
}
|
|
5203
|
+
/** Every record currently held, for status display. */
|
|
5204
|
+
all() {
|
|
5205
|
+
return { ...this.load() };
|
|
5206
|
+
}
|
|
5207
|
+
/** Build a record stamped with this store's clock, version, and account. */
|
|
5208
|
+
record(fingerprint, validation, efforts, account) {
|
|
5209
|
+
return {
|
|
5210
|
+
fingerprint,
|
|
5211
|
+
validation,
|
|
5212
|
+
efforts: validation === "validating" ? [...efforts] : [],
|
|
5213
|
+
probedAtMs: this.now(),
|
|
5214
|
+
pluginVersion: this.pluginVersion,
|
|
5215
|
+
account
|
|
5216
|
+
};
|
|
5217
|
+
}
|
|
5218
|
+
/**
|
|
5219
|
+
* Write through a temporary file and rename, so a crash mid-write cannot
|
|
5220
|
+
* leave a half-parsed document that reads as "no records" and silently drops
|
|
5221
|
+
* every observation.
|
|
5222
|
+
*/
|
|
5223
|
+
persist() {
|
|
5224
|
+
const directory = dirname(this.path);
|
|
5225
|
+
try {
|
|
5226
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
5227
|
+
const document = {
|
|
5228
|
+
version: PROBE_FORMAT_VERSION,
|
|
5229
|
+
records: this.load()
|
|
5230
|
+
};
|
|
5231
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
5232
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
5233
|
+
renameSync(temporary, this.path);
|
|
5234
|
+
} catch {}
|
|
5235
|
+
}
|
|
5236
|
+
};
|
|
5237
|
+
/**
|
|
5238
|
+
* Order observations newest-first for display.
|
|
5239
|
+
*
|
|
5240
|
+
* The store keeps insertion order so the file reads chronologically, but the
|
|
5241
|
+
* card wants the most recent detection at the top: a sweep the user just ran
|
|
5242
|
+
* should not appear below every earlier one, which is what appending to an
|
|
5243
|
+
* insertion-ordered list does.
|
|
5244
|
+
*/
|
|
5245
|
+
function newestFirst(records) {
|
|
5246
|
+
return [...records].sort((a, b) => b.probedAt - a.probedAt);
|
|
5247
|
+
}
|
|
5248
|
+
//#endregion
|
|
4826
5249
|
//#region src/version.ts
|
|
4827
|
-
const QODER_CONNECT_VERSION = "0.2.
|
|
5250
|
+
const QODER_CONNECT_VERSION = "0.2.8";
|
|
4828
5251
|
//#endregion
|
|
4829
5252
|
//#region src/host-heartbeat.ts
|
|
4830
5253
|
/**
|
|
@@ -5021,4 +5444,4 @@ function variantFor(id) {
|
|
|
5021
5444
|
return QODER_VARIANTS.find((variant) => variant.id === id);
|
|
5022
5445
|
}
|
|
5023
5446
|
//#endregion
|
|
5024
|
-
export {
|
|
5447
|
+
export { configurePluginPaths as $, probeModel as A, QODER_PROBE_PATH as B, normalizeCredits as C, utc8WallClock as D, formatUtc8Date as E, QoderCatalog as F, QODER_PAT_ENV_GLOBAL as G, QODER_STATUS_PATH as H, QODER_AUTH_PATH as I, qoderCredentialId as J, QoderCredentialStore as K, QODER_GLOBAL_AUTH_PATH as L, QoderCatalogStore as M, qoderCatalogPath as N, getMachineId as O, FALLBACK_QODER_MODELS as P, QODER_DATA_DIR_NAME as Q, QODER_GLOBAL_PROBE_PATH as R, modelInfoOf as S, createQoderTransport as T, QODER_AUTH_FILENAME as U, QODER_SETTINGS_FACE_PATH as V, QODER_PAT_ENV_CN as W, qoderOwnAuthPath as X, qoderCredentialIdentity as Y, QODER_DATA_DIR_ENV as Z, qoderProbePath as _, QODER_HOST_HEARTBEAT_FILENAME as a, classifyUpstreamError as b, processStartTimeMs as c, writeHostHeartbeat as d, qoderMachineIdPath as et, QODER_CONNECT_VERSION as f, newestFirst as g, fingerprintModel as h, variantFor as i, randomSentinel as j, PROBE_EFFORT_CANDIDATES as k, qoderHostHeartbeatPath as l, QoderProbeStore as m, GLOBAL_VARIANT as n, clearHostHeartbeat as o, QODER_PROBE_FILENAME as p, patTail as q, QODER_VARIANTS as r, isHeartbeatProcessAlive as s, CHINA_VARIANT as t, qoderPluginDataDir as tt, readHostHeartbeat as u, KIND_STATUS as v, validateApiKey as w, kindFromQoderFailure as x, QoderUpstreamClient as y, QODER_GLOBAL_STATUS_PATH as z };
|