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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { A as QoderCatalog, B as QODER_PAT_ENV_GLOBAL, C as getMachineId, D as QoderCatalogStore, E as randomSentinel, F as QODER_PROBE_PATH, G as QODER_DATA_DIR_ENV, H as patTail, I as QODER_SETTINGS_FACE_PATH, J as qoderMachineIdPath, K as QODER_DATA_DIR_NAME, L as QODER_STATUS_PATH, M as QODER_GLOBAL_AUTH_PATH, N as QODER_GLOBAL_PROBE_PATH, O as qoderCatalogPath, P as QODER_GLOBAL_STATUS_PATH, R as QODER_AUTH_FILENAME, S as utc8WallClock, T as probeModel, U as qoderCredentialIdentity, V as QoderCredentialStore, W as qoderOwnAuthPath, X as qoderStateDir, Y as qoderPluginDataDir, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_AUTH_PATH, k as FALLBACK_QODER_MODELS, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as configurePluginPaths, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as PROBE_EFFORT_CANDIDATES, x as formatUtc8Date, y as validateApiKey, z as QODER_PAT_ENV_CN } from "./variants-COxX6r1j.js";
1
+ import { $ as configurePluginPaths, A as probeModel, B as QODER_PROBE_PATH, C as normalizeCredits, D as utc8WallClock, E as formatUtc8Date, F as QoderCatalog, G as QODER_PAT_ENV_GLOBAL, H as QODER_STATUS_PATH, I as QODER_AUTH_PATH, J as qoderCredentialId, K as QoderCredentialStore, L as QODER_GLOBAL_AUTH_PATH, M as QoderCatalogStore, N as qoderCatalogPath, O as getMachineId, P as FALLBACK_QODER_MODELS, Q as QODER_DATA_DIR_NAME, R as QODER_GLOBAL_PROBE_PATH, S as modelInfoOf, T as createQoderTransport, U as QODER_AUTH_FILENAME, V as QODER_SETTINGS_FACE_PATH, W as QODER_PAT_ENV_CN, X as qoderOwnAuthPath, Y as qoderCredentialIdentity, Z as QODER_DATA_DIR_ENV, _ as qoderProbePath, a as QODER_HOST_HEARTBEAT_FILENAME, b as classifyUpstreamError, c as processStartTimeMs, d as writeHostHeartbeat, et as qoderMachineIdPath, f as QODER_CONNECT_VERSION, g as newestFirst, h as fingerprintModel, i as variantFor, j as randomSentinel, k as PROBE_EFFORT_CANDIDATES, l as qoderHostHeartbeatPath, m as QoderProbeStore, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as QODER_PROBE_FILENAME, q as patTail, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, tt as qoderPluginDataDir, u as readHostHeartbeat, v as KIND_STATUS, w as validateApiKey, x as kindFromQoderFailure, y as QoderUpstreamClient, z as QODER_GLOBAL_STATUS_PATH } from "./variants-Dl1AbWre.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { dirname, join } from "node:path";
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { createProvider } from "@earendil-works/pi-ai";
7
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
@@ -60,7 +60,8 @@ function originIsLoopback(origin) {
60
60
  //#endregion
61
61
  //#region src/auth-route.ts
62
62
  /**
63
- * PAT route: saves (validate-then-persist) or clears one variant's token.
63
+ * PAT route: saves (validate-then-persist), switches, removes, or clears the
64
+ * tokens of one variant.
64
65
  *
65
66
  * The state-changing endpoints the plugin exposes share one guard shape — a
66
67
  * loopback Host and Origin, plus the in-process key the card receives with its
@@ -111,9 +112,20 @@ function parseRequest(text) {
111
112
  if (action === "save-pat") {
112
113
  const pat = wrapped["pat"];
113
114
  if (typeof pat !== "string" || pat.trim() === "") return void 0;
115
+ const label = wrapped["label"];
116
+ if (label !== void 0 && (typeof label !== "string" || label.length > 200)) return void 0;
114
117
  return {
115
118
  action,
116
- pat
119
+ pat,
120
+ ...label === void 0 ? {} : { label }
121
+ };
122
+ }
123
+ if (action === "activate" || action === "remove") {
124
+ const id = wrapped["id"];
125
+ if (typeof id !== "string" || id.trim() === "") return void 0;
126
+ return {
127
+ action,
128
+ id: id.trim()
117
129
  };
118
130
  }
119
131
  }
@@ -143,7 +155,7 @@ function keyMatches$1(expected, presented) {
143
155
  * The PAT route's handler, extracted so tests can mount it on a bare server
144
156
  * with a known key.
145
157
  *
146
- * @param deps - the save/clear operations for one variant.
158
+ * @param deps - the save/activate/remove/clear operations for one variant.
147
159
  * @param key - the in-process control key this route requires.
148
160
  * @returns the Node request handler.
149
161
  */
@@ -177,7 +189,15 @@ function qoderAuthHandler(deps, key) {
177
189
  json$2(res, 200, { ok: true });
178
190
  return;
179
191
  }
180
- json$2(res, 200, await deps.save(request.pat));
192
+ if (request.action === "activate") {
193
+ json$2(res, 200, await deps.activate(request.id));
194
+ return;
195
+ }
196
+ if (request.action === "remove") {
197
+ json$2(res, 200, await deps.remove(request.id));
198
+ return;
199
+ }
200
+ json$2(res, 200, await deps.save(request.pat, request.label));
181
201
  } catch (error) {
182
202
  json$2(res, 200, {
183
203
  ok: false,
@@ -201,6 +221,245 @@ function registerQoderAuthRoute(ctx, deps, key) {
201
221
  }, "dsh-qoder-connect: PAT route");
202
222
  }
203
223
  //#endregion
224
+ //#region src/shim.ts
225
+ /**
226
+ * Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
227
+ * shim guards the local boundary and hands the raw JSON body to the upstream
228
+ * client, which translates it onto the Qoder transport. It binds 127.0.0.1
229
+ * only and never serves another interface.
230
+ *
231
+ * Inbound hardening: the loopback bind alone is not a trust boundary (any
232
+ * local process or a DNS-rebinding page can reach 127.0.0.1), so every
233
+ * request must carry a loopback Host header, browser-sent Origins must be
234
+ * loopback, chat POSTs must be application/json, and the Authorization
235
+ * header must carry the shim's per-process shared secret. The plugin's
236
+ * own client satisfies all four by construction; local attackers cannot
237
+ * read the secret out of the plugin process's memory.
238
+ *
239
+ * @module dsh-qoder-connect/shim
240
+ */
241
+ const REQUEST_BODY_LIMIT = 67108864;
242
+ /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
243
+ function isJsonContentType(req) {
244
+ const type = req.headers["content-type"];
245
+ return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
246
+ }
247
+ function writeJson(res, status, body, headers = {}) {
248
+ const payload = JSON.stringify(body);
249
+ res.writeHead(status, {
250
+ "Content-Type": "application/json",
251
+ "Content-Length": Buffer.byteLength(payload),
252
+ ...headers
253
+ });
254
+ res.end(payload);
255
+ }
256
+ /**
257
+ * The prefix the shim stamps onto a failure message carrying an upstream retry
258
+ * hint.
259
+ *
260
+ * The hint has to cross two text-only boundaries to reach the harness retry
261
+ * policy: pi-ai flattens an HTTP failure to its message and the adapter may only
262
+ * rewrite that message. So the marker is written here, in front of the 400-byte
263
+ * excerpt (never inside it, where truncation could cut it off), and read back by
264
+ * {@link retryAfterFromMessage}.
265
+ */
266
+ const RETRY_AFTER_TAG = /\[retry-after=(\d+)s\]/u;
267
+ /**
268
+ * The upstream's retry hint, in milliseconds, recovered from a shim failure
269
+ * message.
270
+ *
271
+ * The tagged form is the contract this plugin writes itself. The untagged
272
+ * `retryAfterSeconds` form is the fallback for a message the transport built
273
+ * before the tag existed (an installed older shim, or a caller that composed the
274
+ * message itself), and is the same field {@link qoderQueueSignal} reads.
275
+ */
276
+ function retryAfterFromMessage(message) {
277
+ const tagged = RETRY_AFTER_TAG.exec(message);
278
+ if (tagged !== null) {
279
+ const seconds = Number(tagged[1]);
280
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
281
+ }
282
+ const body = /"retryAfterSeconds"\s*:\s*(\d+)/u.exec(message);
283
+ if (body !== null) {
284
+ const seconds = Number(body[1]);
285
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
286
+ }
287
+ }
288
+ /**
289
+ * Seconds an OpenAI `Retry-After` header carries: the ceiling of the hint, so a
290
+ * client that honors it never returns before the upstream's window closed.
291
+ */
292
+ function retryAfterSeconds(retryAfterMs) {
293
+ return Math.max(1, Math.ceil(retryAfterMs / 1e3));
294
+ }
295
+ function writeOpenAIError(res, status, kind, message, retryAfterMs) {
296
+ const hint = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? Math.floor(retryAfterMs) : void 0;
297
+ if (hint === void 0) {
298
+ writeJson(res, status, { error: {
299
+ message,
300
+ type: kind,
301
+ code: kind
302
+ } });
303
+ return;
304
+ }
305
+ const seconds = retryAfterSeconds(hint);
306
+ writeJson(res, status, { error: {
307
+ message: `[retry-after=${seconds}s] ${message}`,
308
+ type: kind,
309
+ code: kind
310
+ } }, {
311
+ "Retry-After": String(seconds),
312
+ "retry-after-ms": String(hint)
313
+ });
314
+ }
315
+ /** Read a request body with a size cap; over-limit bodies fail the request. */
316
+ function readBody$1(req) {
317
+ return new Promise((resolve, reject) => {
318
+ const chunks = [];
319
+ let size = 0;
320
+ req.on("data", (chunk) => {
321
+ size += chunk.length;
322
+ if (size > REQUEST_BODY_LIMIT) {
323
+ reject(/* @__PURE__ */ new Error("request body too large"));
324
+ req.destroy();
325
+ return;
326
+ }
327
+ chunks.push(chunk);
328
+ });
329
+ req.on("end", () => resolve(Buffer.concat(chunks)));
330
+ req.on("error", reject);
331
+ });
332
+ }
333
+ /**
334
+ * Start the loopback endpoint. Requests carry any bearer; the loopback bind
335
+ * is the boundary, and the upstream credential comes from the store alone.
336
+ */
337
+ function createQoderShim(options) {
338
+ const { store, client, catalog } = options;
339
+ const logger = options.logger;
340
+ const providerId = options.providerId;
341
+ const SHARED_SECRET = randomBytes(32).toString("base64url");
342
+ /** Constant-time bearer check; absent or mismatched bearers are rejected. */
343
+ function bearerOk(req) {
344
+ const header = req.headers.authorization;
345
+ if (typeof header !== "string") return false;
346
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
347
+ if (match === null) return false;
348
+ const presented = match[1];
349
+ const expected = SHARED_SECRET;
350
+ const a = Buffer.from(presented);
351
+ const b = Buffer.from(expected);
352
+ if (a.length !== b.length) return false;
353
+ return timingSafeEqual(a, b);
354
+ }
355
+ const server = createServer((req, res) => {
356
+ handle(req, res).catch((error) => {
357
+ logger?.warn("dsh-qoder-connect: loopback request failed", error);
358
+ try {
359
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", "loopback request failed");
360
+ else res.end();
361
+ } catch {}
362
+ });
363
+ });
364
+ const ready = new Promise((resolve, reject) => {
365
+ server.once("listening", () => resolve());
366
+ server.once("error", reject);
367
+ });
368
+ server.listen(0, "127.0.0.1");
369
+ const baseUrl = () => {
370
+ const address = server.address();
371
+ if (address === null || typeof address === "string") throw new Error(`${providerId} shim has no listening address`);
372
+ return `http://127.0.0.1:${address.port}`;
373
+ };
374
+ async function handle(req, res) {
375
+ try {
376
+ if (!hostIsLoopback(req.headers.host)) {
377
+ writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
378
+ return;
379
+ }
380
+ if (!originIsLoopback(req.headers.origin)) {
381
+ writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
382
+ return;
383
+ }
384
+ if (!bearerOk(req)) {
385
+ writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
386
+ return;
387
+ }
388
+ const url = req.url ?? "/";
389
+ if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
390
+ writeJson(res, 200, { ok: true });
391
+ return;
392
+ }
393
+ if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
394
+ writeJson(res, 200, {
395
+ object: "list",
396
+ data: catalog.current().map((model) => ({
397
+ id: model.id,
398
+ object: "model",
399
+ created: 0,
400
+ owned_by: providerId
401
+ }))
402
+ });
403
+ return;
404
+ }
405
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
406
+ await chatCompletions(req, res);
407
+ return;
408
+ }
409
+ writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
410
+ } catch (error) {
411
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
412
+ else res.end();
413
+ }
414
+ }
415
+ async function chatCompletions(req, res) {
416
+ if (!isJsonContentType(req)) {
417
+ writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
418
+ return;
419
+ }
420
+ try {
421
+ await store.resolve();
422
+ } catch (error) {
423
+ writeOpenAIError(res, KIND_STATUS.missing_credential, "not_signed_in", String(error));
424
+ return;
425
+ }
426
+ const raw = (await readBody$1(req)).toString("utf8");
427
+ const controller = new AbortController();
428
+ req.on("close", () => controller.abort());
429
+ const result = await client.chatStream(raw, controller.signal);
430
+ if (!result.ok) {
431
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${KIND_STATUS[result.kind]}): ${result.message.slice(0, 400)}`, result.retryAfterMs);
432
+ return;
433
+ }
434
+ res.writeHead(200, {
435
+ "Content-Type": "text/event-stream",
436
+ "Cache-Control": "no-cache",
437
+ "Connection": "keep-alive",
438
+ "X-Accel-Buffering": "no"
439
+ });
440
+ let sawDone = false;
441
+ const body = Readable.fromWeb(result.response.body);
442
+ body.on("data", (chunk) => {
443
+ if (chunk.includes("[DONE]")) sawDone = true;
444
+ });
445
+ body.on("error", (error) => {
446
+ logger?.warn("dsh-qoder-connect: upstream stream failed mid-flight", error);
447
+ if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
448
+ });
449
+ body.pipe(res);
450
+ }
451
+ return {
452
+ ready,
453
+ baseUrl,
454
+ token: () => SHARED_SECRET,
455
+ close: () => new Promise((resolve, reject) => {
456
+ server.close(() => resolve());
457
+ server.closeAllConnections();
458
+ server.once("error", reject);
459
+ })
460
+ };
461
+ }
462
+ //#endregion
204
463
  //#region src/adapter.ts
205
464
  /**
206
465
  * The Qoder pi-ai provider: one loopback-backed adapter per variant route,
@@ -472,6 +731,64 @@ var QoderPiAiAdapter = class QoderPiAiAdapter extends PiAiAdapter {
472
731
  };
473
732
  }
474
733
  /**
734
+ * Carry the upstream's retry hint onto the finish failure.
735
+ *
736
+ * `dsh-llm-pi-ai` maps a failed pi-ai turn onto `{ message, code }` before the
737
+ * chunk reaches the loop: the HTTP status and headers pi-ai's own error object
738
+ * still holds are flattened away, so `failure.providerRetryAfterMs` — the one
739
+ * field the harness retry policy honors — never arrives. The policy then falls
740
+ * back to its local backoff, which is capped (20s in the report that prompted
741
+ * this) below Qoder's 30-second queue answer, so every retry hits the same
742
+ * closed window.
743
+ *
744
+ * The shim stamps `[retry-after=<n>s]` in front of its error message for this
745
+ * layer to recover; see `retryAfterFromMessage`. Rewriting only the field,
746
+ * never the message, keeps the text a human reads unchanged.
747
+ *
748
+ * Both entry points are wrapped because `PiAiAdapter` has two of them and the
749
+ * harness uses the other one: `prepareCall()` returns
750
+ * `stream: (options) => this.streamWithSnapshot(options, snapshot)`, which
751
+ * never calls `this.stream`. Overriding `stream` alone is dead code on every
752
+ * harness request (verified against a live session log: the finish failure
753
+ * arrived as bare `{ message, code }` while a direct `adapter.stream()` call
754
+ * carried the hint).
755
+ */
756
+ async *stream(options) {
757
+ yield* this.withRetryHint(super.stream(options));
758
+ }
759
+ async prepareCall(provider, model, signal) {
760
+ const prepared = await super.prepareCall(provider, model, signal);
761
+ return {
762
+ ...prepared,
763
+ stream: (options) => this.withRetryHint(prepared.stream(options))
764
+ };
765
+ }
766
+ /** Re-stamp `failure.providerRetryAfterMs` on every terminal failure chunk. */
767
+ async *withRetryHint(inner) {
768
+ for await (const chunk of inner) {
769
+ if (chunk.type === "finish") {
770
+ const { reason } = chunk;
771
+ if ((reason.kind === "error" || reason.kind === "aborted") && reason.failure.providerRetryAfterMs === void 0) {
772
+ const retryAfterMs = retryAfterFromMessage(reason.failure.message);
773
+ if (retryAfterMs !== void 0) {
774
+ yield {
775
+ ...chunk,
776
+ reason: {
777
+ ...reason,
778
+ failure: {
779
+ ...reason.failure,
780
+ providerRetryAfterMs: retryAfterMs
781
+ }
782
+ }
783
+ };
784
+ continue;
785
+ }
786
+ }
787
+ }
788
+ yield chunk;
789
+ }
790
+ }
791
+ /**
475
792
  * Resolve the request-image projection target for one route.
476
793
  *
477
794
  * `dsh-llm` 0.2.1 added `LlmAdapter.imageRequestTarget()` and its runtime calls
@@ -497,389 +814,6 @@ var QoderPiAiAdapter = class QoderPiAiAdapter extends PiAiAdapter {
497
814
  }
498
815
  };
499
816
  //#endregion
500
- //#region src/shim.ts
501
- /**
502
- * Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
503
- * shim guards the local boundary and hands the raw JSON body to the upstream
504
- * client, which translates it onto the Qoder transport. It binds 127.0.0.1
505
- * only and never serves another interface.
506
- *
507
- * Inbound hardening: the loopback bind alone is not a trust boundary (any
508
- * local process or a DNS-rebinding page can reach 127.0.0.1), so every
509
- * request must carry a loopback Host header, browser-sent Origins must be
510
- * loopback, chat POSTs must be application/json, and the Authorization
511
- * header must carry the shim's per-process shared secret. The plugin's
512
- * own client satisfies all four by construction; local attackers cannot
513
- * read the secret out of the plugin process's memory.
514
- *
515
- * @module dsh-qoder-connect/shim
516
- */
517
- const REQUEST_BODY_LIMIT = 67108864;
518
- /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
519
- function isJsonContentType(req) {
520
- const type = req.headers["content-type"];
521
- return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
522
- }
523
- function writeJson(res, status, body) {
524
- const payload = JSON.stringify(body);
525
- res.writeHead(status, {
526
- "Content-Type": "application/json",
527
- "Content-Length": Buffer.byteLength(payload)
528
- });
529
- res.end(payload);
530
- }
531
- function writeOpenAIError(res, status, kind, message) {
532
- writeJson(res, status, { error: {
533
- message,
534
- type: kind,
535
- code: kind
536
- } });
537
- }
538
- /** Read a request body with a size cap; over-limit bodies fail the request. */
539
- function readBody$1(req) {
540
- return new Promise((resolve, reject) => {
541
- const chunks = [];
542
- let size = 0;
543
- req.on("data", (chunk) => {
544
- size += chunk.length;
545
- if (size > REQUEST_BODY_LIMIT) {
546
- reject(/* @__PURE__ */ new Error("request body too large"));
547
- req.destroy();
548
- return;
549
- }
550
- chunks.push(chunk);
551
- });
552
- req.on("end", () => resolve(Buffer.concat(chunks)));
553
- req.on("error", reject);
554
- });
555
- }
556
- /**
557
- * Start the loopback endpoint. Requests carry any bearer; the loopback bind
558
- * is the boundary, and the upstream credential comes from the store alone.
559
- */
560
- function createQoderShim(options) {
561
- const { store, client, catalog } = options;
562
- const logger = options.logger;
563
- const providerId = options.providerId;
564
- const SHARED_SECRET = randomBytes(32).toString("base64url");
565
- /** Constant-time bearer check; absent or mismatched bearers are rejected. */
566
- function bearerOk(req) {
567
- const header = req.headers.authorization;
568
- if (typeof header !== "string") return false;
569
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
570
- if (match === null) return false;
571
- const presented = match[1];
572
- const expected = SHARED_SECRET;
573
- const a = Buffer.from(presented);
574
- const b = Buffer.from(expected);
575
- if (a.length !== b.length) return false;
576
- return timingSafeEqual(a, b);
577
- }
578
- const server = createServer((req, res) => {
579
- handle(req, res).catch((error) => {
580
- logger?.warn("dsh-qoder-connect: loopback request failed", error);
581
- try {
582
- if (!res.headersSent) writeOpenAIError(res, 500, "internal", "loopback request failed");
583
- else res.end();
584
- } catch {}
585
- });
586
- });
587
- const ready = new Promise((resolve, reject) => {
588
- server.once("listening", () => resolve());
589
- server.once("error", reject);
590
- });
591
- server.listen(0, "127.0.0.1");
592
- const baseUrl = () => {
593
- const address = server.address();
594
- if (address === null || typeof address === "string") throw new Error(`${providerId} shim has no listening address`);
595
- return `http://127.0.0.1:${address.port}`;
596
- };
597
- async function handle(req, res) {
598
- try {
599
- if (!hostIsLoopback(req.headers.host)) {
600
- writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
601
- return;
602
- }
603
- if (!originIsLoopback(req.headers.origin)) {
604
- writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
605
- return;
606
- }
607
- if (!bearerOk(req)) {
608
- writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
609
- return;
610
- }
611
- const url = req.url ?? "/";
612
- if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
613
- writeJson(res, 200, { ok: true });
614
- return;
615
- }
616
- if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
617
- writeJson(res, 200, {
618
- object: "list",
619
- data: catalog.current().map((model) => ({
620
- id: model.id,
621
- object: "model",
622
- created: 0,
623
- owned_by: providerId
624
- }))
625
- });
626
- return;
627
- }
628
- if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
629
- await chatCompletions(req, res);
630
- return;
631
- }
632
- writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
633
- } catch (error) {
634
- if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
635
- else res.end();
636
- }
637
- }
638
- async function chatCompletions(req, res) {
639
- if (!isJsonContentType(req)) {
640
- writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
641
- return;
642
- }
643
- try {
644
- await store.resolve();
645
- } catch (error) {
646
- writeOpenAIError(res, KIND_STATUS.missing_credential, "not_signed_in", String(error));
647
- return;
648
- }
649
- const raw = (await readBody$1(req)).toString("utf8");
650
- const controller = new AbortController();
651
- req.on("close", () => controller.abort());
652
- const result = await client.chatStream(raw, controller.signal);
653
- if (!result.ok) {
654
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${KIND_STATUS[result.kind]}): ${result.message.slice(0, 400)}`);
655
- return;
656
- }
657
- res.writeHead(200, {
658
- "Content-Type": "text/event-stream",
659
- "Cache-Control": "no-cache",
660
- "Connection": "keep-alive",
661
- "X-Accel-Buffering": "no"
662
- });
663
- let sawDone = false;
664
- const body = Readable.fromWeb(result.response.body);
665
- body.on("data", (chunk) => {
666
- if (chunk.includes("[DONE]")) sawDone = true;
667
- });
668
- body.on("error", (error) => {
669
- logger?.warn("dsh-qoder-connect: upstream stream failed mid-flight", error);
670
- if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
671
- });
672
- body.pipe(res);
673
- }
674
- return {
675
- ready,
676
- baseUrl,
677
- token: () => SHARED_SECRET,
678
- close: () => new Promise((resolve, reject) => {
679
- server.close(() => resolve());
680
- server.closeAllConnections();
681
- server.once("error", reject);
682
- })
683
- };
684
- }
685
- //#endregion
686
- //#region src/probe-store.ts
687
- /**
688
- * Local record of reasoning-effort probes.
689
- *
690
- * What this stores is an *observation*, never a claim about the upstream: a
691
- * model's row is only consulted when the catalog carries no explicit
692
- * `supportedEfforts` set, and it always loses to a declared set. The plan this
693
- * implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
694
- * is invalidated whenever the model's catalog row changes, so every record
695
- * carries a fingerprint of the fields the probe depended on.
696
- *
697
- * The file lives in the plugin's own state directory, never beside the
698
- * product's files, and carries no token, prompt, or response body — only
699
- * model ids, effort spellings, and timestamps.
700
- *
701
- * @module dsh-qoder-connect/probe-store
702
- */
703
- /** Basename of the China variant's probe record inside the plugin's state dir. */
704
- const QODER_PROBE_FILENAME = ".qoder-probe.json";
705
- /** On-disk format this reader accepts; other versions are discarded. */
706
- const PROBE_FORMAT_VERSION = 1;
707
- /**
708
- * How long an observation stays usable. Conservative on purpose: the plan's
709
- * whole argument is that upstream metadata moves fast, so a result that has
710
- * outlived its fingerprint's usefulness should not quietly keep granting a
711
- * picker entry.
712
- */
713
- const DEFAULT_TTL_MS = 12096e5;
714
- /**
715
- * Plugin-owned probe record path inside the plugin's state directory.
716
- *
717
- * One file per variant. The two regions serve overlapping model ids with
718
- * different entitlements, and {@link fingerprintModel} covers only
719
- * `id`/`reasoning`/`supportsImages` — never the provider — so a single shared
720
- * file would let one variant's observation answer for the other. The paths
721
- * differ; the format does not.
722
- */
723
- function qoderProbePath(filename = QODER_PROBE_FILENAME) {
724
- return join(qoderStateDir(), filename);
725
- }
726
- /**
727
- * Fingerprint the catalog fields a probe depends on.
728
- *
729
- * Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
730
- * so a rename or a rate relabel does not throw away a valid observation, and
731
- * deliberately includes the whole reasoning object so any change to the
732
- * declared shape re-probes.
733
- */
734
- function fingerprintModel(info) {
735
- const basis = JSON.stringify({
736
- id: info.id,
737
- reasoning: info.reasoning ?? null,
738
- supportsImages: info.supportsImages ?? null
739
- });
740
- return createHash("sha256").update(basis).digest("hex").slice(0, 16);
741
- }
742
- /** Read-and-validate the documents on disk; anything malformed reads as empty. */
743
- function readDocument(path) {
744
- if (!existsSync(path)) return void 0;
745
- let parsed;
746
- try {
747
- parsed = JSON.parse(readFileSync(path, "utf8"));
748
- } catch {
749
- return;
750
- }
751
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
752
- const wrapped = parsed;
753
- if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
754
- const records = wrapped["records"];
755
- if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
756
- return parsed;
757
- }
758
- /** One record's shape check; a bad row is dropped rather than trusted. */
759
- function isRecord(value) {
760
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
761
- const wrapped = value;
762
- const validation = wrapped["validation"];
763
- if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
764
- if (typeof wrapped["fingerprint"] !== "string") return false;
765
- if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
766
- if (typeof wrapped["pluginVersion"] !== "string") return false;
767
- const efforts = wrapped["efforts"];
768
- if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
769
- return true;
770
- }
771
- /**
772
- * The plugin's probe records: read once, written atomically, never trusted
773
- * across a fingerprint change or past the TTL.
774
- */
775
- var QoderProbeStore = class {
776
- path;
777
- ttlMs;
778
- pluginVersion;
779
- now;
780
- records;
781
- constructor(options) {
782
- const opts = typeof options === "string" ? {
783
- path: options,
784
- pluginVersion: "0.0.0"
785
- } : options;
786
- this.path = opts.path ?? qoderProbePath();
787
- this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
788
- this.pluginVersion = opts.pluginVersion;
789
- this.now = opts.now ?? (() => Date.now());
790
- }
791
- /** Resolved state-file path, for the CLI and tests. */
792
- filePath() {
793
- return this.path;
794
- }
795
- load() {
796
- if (this.records === void 0) {
797
- const document = readDocument(this.path);
798
- const records = {};
799
- for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
800
- this.records = records;
801
- }
802
- return this.records;
803
- }
804
- /**
805
- * The usable record for a model, or `undefined` when there is none, it is
806
- * expired, it was taken against a different catalog row, or it belongs to a
807
- * different account.
808
- *
809
- * @param account - the account in effect, as `uid:enterpriseId`. Records are
810
- * only returned for the account that produced them.
811
- */
812
- get(modelId, fingerprint, account) {
813
- const record = this.load()[modelId];
814
- if (record === void 0) return void 0;
815
- if (record.fingerprint !== fingerprint) return void 0;
816
- if (record.account !== account) return void 0;
817
- if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
818
- return record;
819
- }
820
- /**
821
- * Store one observation. Only a decisive answer (`validating` /
822
- * `non-validating`) replaces an existing decisive record: a transient
823
- * `unknown` must not erase knowledge the user already paid for.
824
- */
825
- set(modelId, record) {
826
- const records = this.load();
827
- const existing = records[modelId];
828
- if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
829
- records[modelId] = record;
830
- this.persist();
831
- }
832
- /** Drop every record; used by the card's explicit "clear" action. */
833
- clear() {
834
- this.records = {};
835
- this.persist();
836
- }
837
- /** Every record currently held, for status display. */
838
- all() {
839
- return { ...this.load() };
840
- }
841
- /** Build a record stamped with this store's clock, version, and account. */
842
- record(fingerprint, validation, efforts, account) {
843
- return {
844
- fingerprint,
845
- validation,
846
- efforts: validation === "validating" ? [...efforts] : [],
847
- probedAtMs: this.now(),
848
- pluginVersion: this.pluginVersion,
849
- account
850
- };
851
- }
852
- /**
853
- * Write through a temporary file and rename, so a crash mid-write cannot
854
- * leave a half-parsed document that reads as "no records" and silently drops
855
- * every observation.
856
- */
857
- persist() {
858
- const directory = dirname(this.path);
859
- try {
860
- if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
861
- const document = {
862
- version: PROBE_FORMAT_VERSION,
863
- records: this.load()
864
- };
865
- const temporary = resolve(`${this.path}.tmp`);
866
- writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
867
- renameSync(temporary, this.path);
868
- } catch {}
869
- }
870
- };
871
- /**
872
- * Order observations newest-first for display.
873
- *
874
- * The store keeps insertion order so the file reads chronologically, but the
875
- * card wants the most recent detection at the top: a sweep the user just ran
876
- * should not appear below every earlier one, which is what appending to an
877
- * insertion-ordered list does.
878
- */
879
- function newestFirst(records) {
880
- return [...records].sort((a, b) => b.probedAt - a.probedAt);
881
- }
882
- //#endregion
883
817
  //#region src/probe-service.ts
884
818
  /**
885
819
  * Serial probe runner. One instance is shared by the manual API and any
@@ -1070,6 +1004,8 @@ async function qoderWebStatus(deps) {
1070
1004
  status: "signed-in",
1071
1005
  ...authStatus.region === void 0 ? {} : { region: authStatus.region },
1072
1006
  ...authStatus.pat === void 0 ? {} : { pat: authStatus.pat },
1007
+ ...authStatus.profiles === void 0 ? {} : { patProfiles: authStatus.profiles },
1008
+ ...authStatus.activeId === void 0 ? {} : { activePatId: authStatus.activeId },
1073
1009
  ...deps.authKey === void 0 ? {} : { authKey: deps.authKey }
1074
1010
  };
1075
1011
  const modelsField = deps.models().map(modelSnapshot);
@@ -2687,10 +2623,7 @@ function apply(ctx, config) {
2687
2623
  runtime.catalogGeneration += 1;
2688
2624
  runtime.inflightFetch?.controller.abort();
2689
2625
  runtime.inflightFetch = void 0;
2690
- if (hadCredential && known !== identity) {
2691
- runtime.probeStore.clear();
2692
- runtime.invalidate();
2693
- }
2626
+ if (hadCredential && known !== identity) runtime.invalidate();
2694
2627
  if (identity === void 0) {
2695
2628
  if (known !== void 0) runtime.savedCatalogs.delete(known);
2696
2629
  runtime.catalog.set(runtime.fallback);
@@ -2765,13 +2698,13 @@ function apply(ctx, config) {
2765
2698
  });
2766
2699
  registerQoderAuthRoute(webCtx, {
2767
2700
  path: runtime.variant.authPath,
2768
- save: async (pat) => {
2701
+ save: async (pat, label) => {
2769
2702
  if (!await validateApiKey(pat, runtime.variant.region)) return {
2770
2703
  ok: false,
2771
2704
  error: "qoder_invalid_pat"
2772
2705
  };
2773
- const credential = await runtime.store.save(pat);
2774
- const identity = credentialIdentity(credential);
2706
+ const entry = await runtime.store.add(pat, label);
2707
+ const identity = credentialIdentity(entry);
2775
2708
  adoptIdentity(runtime, identity);
2776
2709
  await fetchCatalog(runtime, identity);
2777
2710
  return {
@@ -2779,7 +2712,45 @@ function apply(ctx, config) {
2779
2712
  status: await runtime.store.status()
2780
2713
  };
2781
2714
  },
2715
+ activate: async (id) => {
2716
+ const entry = await runtime.store.activate(id);
2717
+ if (entry === void 0) return {
2718
+ ok: false,
2719
+ error: "qoder_unknown_pat"
2720
+ };
2721
+ const identity = credentialIdentity(entry);
2722
+ adoptIdentity(runtime, identity);
2723
+ detach(ctx, fetchCatalog(runtime, identity), `${runtime.variant.id} catalog refresh after switch`);
2724
+ return {
2725
+ ok: true,
2726
+ status: await runtime.store.status()
2727
+ };
2728
+ },
2729
+ remove: async (id) => {
2730
+ const removed = await runtime.store.remove(id);
2731
+ if (removed === void 0) return {
2732
+ ok: false,
2733
+ error: "qoder_unknown_pat"
2734
+ };
2735
+ const finished = credentialIdentity(removed);
2736
+ runtime.probeStore.clearFor(finished);
2737
+ runtime.savedCatalogs.delete(finished);
2738
+ const active = await runtime.store.current().catch(() => void 0);
2739
+ const identity = active === void 0 ? void 0 : credentialIdentity(active);
2740
+ adoptIdentity(runtime, identity);
2741
+ if (identity !== void 0) detach(ctx, fetchCatalog(runtime, identity), `${runtime.variant.id} catalog refresh after removal`);
2742
+ return {
2743
+ ok: true,
2744
+ status: await runtime.store.status()
2745
+ };
2746
+ },
2782
2747
  clear: async () => {
2748
+ const saved = await runtime.store.entries().catch(() => []);
2749
+ for (const entry of saved) {
2750
+ const identity = credentialIdentity(entry);
2751
+ runtime.probeStore.clearFor(identity);
2752
+ runtime.savedCatalogs.delete(identity);
2753
+ }
2783
2754
  await runtime.store.clear();
2784
2755
  adoptIdentity(runtime, void 0);
2785
2756
  }
@@ -3097,4 +3068,4 @@ function apply(ctx, config) {
3097
3068
  }), "variant startup");
3098
3069
  }
3099
3070
  //#endregion
3100
- export { CHINA_VARIANT, CN_SECTION_KEYS, Config, FALLBACK_QODER_MODELS, GLOBAL_SECTION_KEYS, GLOBAL_VARIANT, KIND_STATUS, PROBE_EFFORT_CANDIDATES, QODER_AUTH_FILENAME, QODER_AUTH_PATH, QODER_DATA_DIR_ENV, QODER_DATA_DIR_NAME, QODER_GLOBAL_AUTH_PATH, QODER_GLOBAL_PROBE_PATH, QODER_GLOBAL_SETTINGS_NS, QODER_GLOBAL_STATUS_PATH, QODER_HOST_HEARTBEAT_FILENAME, QODER_PAT_ENV_CN, QODER_PAT_ENV_GLOBAL, QODER_PROBE_FILENAME, QODER_PROBE_PATH, QODER_PROVIDER, QODER_QUOTA_SETTINGS_NS, QODER_SETTINGS_NS, QODER_STATUS_PATH, QODER_STREAM_IDLE_TIMEOUT_MS, QODER_VARIANTS, QUOTA_POLL_DEFAULT_MS, QUOTA_POLL_MIN_MS, QUOTA_SECTION_KEYS, QoderCatalog, QoderCatalogStore, QoderCredentialStore, QoderProbeService, QoderProbeStore, QoderUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createAuthKey, createQoderAdapter, createQoderShim, fingerprintModel, inject, isHeartbeatProcessAlive, kindFromQoderFailure, modelInfoOf, name, normalizeCredits, patTail, probeModel, processStartTimeMs, qoderAuthHandler, qoderCatalogPath, qoderCredentialIdentity, qoderHostHeartbeatPath, qoderOwnAuthPath, qoderPluginDataDir, qoderProbePath, randomSentinel, readHostHeartbeat, registerQoderAuthRoute, variantFor };
3071
+ export { CHINA_VARIANT, CN_SECTION_KEYS, Config, FALLBACK_QODER_MODELS, GLOBAL_SECTION_KEYS, GLOBAL_VARIANT, KIND_STATUS, PROBE_EFFORT_CANDIDATES, QODER_AUTH_FILENAME, QODER_AUTH_PATH, QODER_DATA_DIR_ENV, QODER_DATA_DIR_NAME, QODER_GLOBAL_AUTH_PATH, QODER_GLOBAL_PROBE_PATH, QODER_GLOBAL_SETTINGS_NS, QODER_GLOBAL_STATUS_PATH, QODER_HOST_HEARTBEAT_FILENAME, QODER_PAT_ENV_CN, QODER_PAT_ENV_GLOBAL, QODER_PROBE_FILENAME, QODER_PROBE_PATH, QODER_PROVIDER, QODER_QUOTA_SETTINGS_NS, QODER_SETTINGS_NS, QODER_STATUS_PATH, QODER_STREAM_IDLE_TIMEOUT_MS, QODER_VARIANTS, QUOTA_POLL_DEFAULT_MS, QUOTA_POLL_MIN_MS, QUOTA_SECTION_KEYS, QoderCatalog, QoderCatalogStore, QoderCredentialStore, QoderProbeService, QoderProbeStore, QoderUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createAuthKey, createQoderAdapter, createQoderShim, fingerprintModel, inject, isHeartbeatProcessAlive, kindFromQoderFailure, modelInfoOf, name, normalizeCredits, patTail, probeModel, processStartTimeMs, qoderAuthHandler, qoderCatalogPath, qoderCredentialId, qoderCredentialIdentity, qoderHostHeartbeatPath, qoderOwnAuthPath, qoderPluginDataDir, qoderProbePath, randomSentinel, readHostHeartbeat, registerQoderAuthRoute, variantFor };